mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e821df35 | |||
| 0fd71c9a00 | |||
| 53b453abc8 | |||
| fe0665506e | |||
| 9bd600c4b1 | |||
| bbd4254637 | |||
| 2603c777ef | |||
| 8942a4ba4f | |||
| f97be749dc | |||
| 5836d5ffc7 | |||
| 91f21fdeb1 | |||
| 844eec132b | |||
| adc4b3f8bf | |||
| fb09631db9 | |||
| 19febb98a5 | |||
| 6bfa11dd8d | |||
| 4758bc84b5 | |||
| 917b182e39 | |||
| e6c3322321 | |||
| 00d34eec9b | |||
| 84792bc00d | |||
| dae32e89a3 | |||
| 367997fd16 | |||
| 751c3329f0 | |||
| 9c5e403b18 | |||
| c944e77bc1 | |||
| d94c1d4252 | |||
| 9f395cb16d | |||
| 46195fc043 | |||
| c9ef8d55c1 | |||
| ad0e821cc0 | |||
| 21454947e0 | |||
| ad69c0ff80 | |||
| 20d9df2501 | |||
| de9af5aaa2 | |||
| 61280b06b7 | |||
| ce4a48cd8d | |||
| 2877475a35 | |||
| 73e23ee323 | |||
| 938c5fbb4c | |||
| e90e94828d | |||
| 1f0401632e | |||
| 5c3989c9b6 | |||
| 6ad26f06c7 | |||
| a63fa68d39 | |||
| 20503de6fd | |||
| 0335da9786 | |||
| 9f1a64f53c | |||
| 975356f4f4 | |||
| 1b448835cd | |||
| cbf3d566b9 | |||
| 6c20cb5e99 | |||
| cfc3aab893 | |||
| b7c48f38a9 | |||
| dcb6bfa62d | |||
| 0abc4de3cd | |||
| f750ed3cdc | |||
| f026c1c1e3 | |||
| ce176b01a2 | |||
| 337dd7892a | |||
| b7daf2926a | |||
| 81a07099ae | |||
| 8f88ca5053 | |||
| 82a885dc44 | |||
| c4be814de5 | |||
| 9f3186e249 | |||
| 5159554697 | |||
| cf690aff08 | |||
| 48c4e5f429 | |||
| ad0da6def4 | |||
| a15eb0db4c | |||
| 70e8fbf327 | |||
| 1044dfac01 | |||
| b5e5c113e2 | |||
| cbcff5e7ab | |||
| 83cb2f82f5 | |||
| 837fd53dfd | |||
| 4962288a53 | |||
| 75a35e8537 | |||
| 6e839fcefb | |||
| 052a8e4ccb | |||
| 969c2bad14 | |||
| 9717222491 | |||
| 081c0bdac4 | |||
| 8b99e91883 | |||
| 4af2e688d1 |
@@ -14,12 +14,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -56,12 +56,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -89,12 +89,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Ontime CLI build
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_cli:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project packages
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Copy server
|
||||||
|
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||||
|
|
||||||
|
- name: Copy client
|
||||||
|
run: cp -R apps/client/build apps/cli/client
|
||||||
|
|
||||||
|
- name: Copy external
|
||||||
|
run: cp -R apps/server/src/external apps/cli/external
|
||||||
|
|
||||||
|
- name: Publish to NPM
|
||||||
|
run: pnpm publish --access public --no-git-checks
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
|
working-directory: ./apps/cli
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Docker Image CI Ontime
|
name: Docker Image CI Ontime
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags: [ "*" ]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -14,8 +14,6 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup env
|
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Docker Login
|
- name: Docker Login
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v2.1.0
|
||||||
@@ -32,10 +30,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
# Push is a shorthand for --output=type=registry
|
# Push is a shorthand for --output=type=registry
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||||
|
|
||||||
- name: Build and push pre-release
|
- name: Build and push pre-release
|
||||||
if: github.event.release.prerelease == true
|
if: github.event.release.prerelease == true
|
||||||
@@ -43,8 +41,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
# Push is a shorthand for --output=type=registry
|
# Push is a shorthand for --output=type=registry
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -73,12 +73,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.18.2
|
node-version: 20
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
|||||||
The steps below will assume you have locally installed the necessary dependencies.
|
The steps below will assume you have locally installed the necessary dependencies.
|
||||||
Other dependencies will be installed as part of the setup
|
Other dependencies will be installed as part of the setup
|
||||||
|
|
||||||
- __node__ (~18.18.2)
|
- __node__ (~20)
|
||||||
- __pnpm__ (>=8)
|
- __pnpm__ (~9)
|
||||||
- __docker__ (only necessary to run and build docker images)
|
- __docker__ (only necessary to run and build docker images)
|
||||||
|
|
||||||
## LOCAL DEVELOPMENT
|
## LOCAL DEVELOPMENT
|
||||||
|
|||||||
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
FROM node:18.18-alpine AS builder
|
FROM node:20-bullseye AS builder
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN npm install -g pnpm@9.5.0
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /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 --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||||
|
|
||||||
FROM node:18.18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
# Environment Variable to signal that we are running production
|
# Environment Variable to signal that we are running production
|
||||||
ENV NODE_ENV=docker
|
ENV NODE_ENV=docker
|
||||||
# Ontime Data path
|
# Ontime Data path
|
||||||
ENV ONTIME_DATA=/external/
|
ENV ONTIME_DATA=/data/
|
||||||
|
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
[](https://github.com/sponsors/cpvalente)
|
[](https://github.com/sponsors/cpvalente)
|
||||||
[](https://www.buymeacoffee.com/cpvalente)
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
"plugins": [],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-var-requires": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
server
|
||||||
|
external
|
||||||
|
client
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Ontime CLI
|
||||||
|
|
||||||
|

|
||||||
|
> Ontime is an application for managing rundown and event timers.
|
||||||
|
|
||||||
|
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
|
||||||
|
|
||||||
|
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
You will need to have installed the correct version of node.js
|
||||||
|
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
|
||||||
|
|
||||||
|
### Running Ontime
|
||||||
|
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
|
||||||
|
|
||||||
|
Install globally in your system
|
||||||
|
```bash
|
||||||
|
npm install -g @getontime/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
... and run using the installed script
|
||||||
|
```bash
|
||||||
|
ontime
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install and run ontime (the installation here is temporary for the duration of the session)
|
||||||
|
```bash
|
||||||
|
npx install @getontime/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- [Ontime's repository](https://github.com/cpvalente/ontime)
|
||||||
|
- [Ontime's documentation](https://docs.getontime.no/)
|
||||||
|
- [Ontime's website](https://getontime.no/)
|
||||||
|
|
||||||
|
## Sponsoring
|
||||||
|
You can help the development of this project or say thank you with a one time donation. \
|
||||||
|
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/cpvalente)
|
||||||
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
|
||||||
|
|
||||||
|
const ontimeServer = require('./server/index.cjs');
|
||||||
|
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
|
||||||
|
|
||||||
|
async function startOntime() {
|
||||||
|
await initAssets();
|
||||||
|
|
||||||
|
await startServer();
|
||||||
|
|
||||||
|
await startIntegrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
startOntime();
|
||||||
|
|
||||||
|
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
|
||||||
|
shutdown();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@getontime/cli",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"author": "Carlos Valente",
|
||||||
|
"description": "Time keeping for live events",
|
||||||
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
"keywords": [
|
||||||
|
"lighdev",
|
||||||
|
"ontime",
|
||||||
|
"timer"
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"main": "main.js",
|
||||||
|
"bin": {
|
||||||
|
"ontime": "main.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"client",
|
||||||
|
"external",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"prettier": "^3.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.2.0",
|
"version": "3.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||||
"@typescript-eslint/parser": "^7.12.0",
|
"@typescript-eslint/parser": "^7.16.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"prettier": "^3.3.1",
|
"prettier": "^3.3.1",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "^5.4.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"vite-plugin-compression2": "^0.12.0",
|
"vite-plugin-compression2": "^0.12.0",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
|
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { socketClientName } from './common/stores/connectionName';
|
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
import theme from './theme/theme';
|
import theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
const preferredClientName = socketClientName.getState().name;
|
connectSocket();
|
||||||
connectSocket(preferredClientName);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -24,11 +23,15 @@ function App() {
|
|||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
<IdentifyOverlay />
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</div>
|
</div>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div id='identify-portal' />
|
||||||
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useClientPath } from './common/hooks/useClientPath';
|
||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import withPreset from './features/PresetWrapper';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import withData from './features/viewers/ViewWrapper';
|
||||||
@@ -34,6 +35,9 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
|
|||||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
export default function AppRouter() {
|
export default function AppRouter() {
|
||||||
|
// handle client path changes
|
||||||
|
useClientPath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
|
|||||||
export const SHEET_STATE = ['sheetState'];
|
export const SHEET_STATE = ['sheetState'];
|
||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// resolve location
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
|||||||
const url = `${dbPath}/${filename}/rename`;
|
const url = `${dbPath}/${filename}/rename`;
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
const res = await axios.put(decodedUrl, {
|
const res = await axios.put(decodedUrl, {
|
||||||
filename: newFilename,
|
newFilename,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.buttonSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: -$element-spacing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftAddon,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RedirectClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||||
|
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||||
|
const [path, setPath] = useState(currentPath);
|
||||||
|
|
||||||
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRedirect = () => {
|
||||||
|
if (path !== currentPath && path !== '') {
|
||||||
|
setRedirect({ target: id, redirect: path });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = `${window.location.origin}/`;
|
||||||
|
const canSubmit = path !== currentPath && path !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<InputGroup variant='ontime-filled' size='md'>
|
||||||
|
<InputLeftAddon>{host}</InputLeftAddon>
|
||||||
|
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RenameClientModalProps {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameClientModal(props: RenameClientModalProps) {
|
||||||
|
const { id, name: currentName = '', isOpen, onClose } = props;
|
||||||
|
const [name, setName] = useState(currentName);
|
||||||
|
|
||||||
|
const { setClientName } = setClientRemote;
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (name !== currentName && name !== '') {
|
||||||
|
setClientName({ target: id, rename: name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = name !== currentName && name !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rename: {currentName}</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
placeholder='new name'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
border: 10px solid $ui-white;
|
||||||
|
background-color: $ui-black;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 100;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: -10vh;
|
||||||
|
line-height: 1em;
|
||||||
|
font-size: 12vw;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: $label-gray;
|
||||||
|
line-height: 2em;
|
||||||
|
font-size: 3vw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
|
|
||||||
|
import style from './IdentifyOverlay.module.scss';
|
||||||
|
|
||||||
|
export default function IdentifyOverlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
if (!showOverlay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalRoot = document.getElementById('identify-portal');
|
||||||
|
|
||||||
|
if (!portalRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createPortal(<Overlay />, portalRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overlay() {
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const { setIdentify } = setClientRemote;
|
||||||
|
const showOverlay = clients[id]?.identify;
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIdentify({ target: id, identify: false });
|
||||||
|
}, [id, setIdentify]);
|
||||||
|
|
||||||
|
// start a timer that will close the overlay after some time
|
||||||
|
useEffect(() => {
|
||||||
|
if (showOverlay) {
|
||||||
|
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showOverlay, id, setIdentify, handleClose]);
|
||||||
|
|
||||||
|
console.log('here2');
|
||||||
|
return (
|
||||||
|
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||||
|
<div className={style.name}>{name}</div>
|
||||||
|
<div className={style.message}>Click to close</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,10 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
|
|||||||
function NavigationMenu(props: NavigationMenuProps) {
|
function NavigationMenu(props: NavigationMenuProps) {
|
||||||
const { isOpen, onClose } = props;
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
const id = useClientStore((store) => store.id);
|
||||||
|
const name = useClientStore((store) => store.name);
|
||||||
|
|
||||||
|
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { toggleMirror } = useViewOptionsStore();
|
const { toggleMirror } = useViewOptionsStore();
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef}>
|
<div id='navigation-menu-portal' ref={menuRef}>
|
||||||
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { setClientName } from '../../../hooks/useSocket';
|
|
||||||
import { useSocketClientName } from '../../../stores/connectionName';
|
|
||||||
|
|
||||||
interface RenameClientModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
|
||||||
const { name: clientName, persistName } = useSocketClientName();
|
|
||||||
const [newName, setNewName] = useState(clientName);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNewName(clientName);
|
|
||||||
}, [isOpen, clientName]);
|
|
||||||
|
|
||||||
const handleRename = async () => {
|
|
||||||
if (newName) {
|
|
||||||
setClientName(newName);
|
|
||||||
persistName(newName);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
size='sm'
|
|
||||||
closeOnOverlayClick={false}
|
|
||||||
motionPreset='slideInBottom'
|
|
||||||
scrollBehavior='inside'
|
|
||||||
preserveScrollBarGap
|
|
||||||
variant='ontime'
|
|
||||||
>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>Rename client</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody>
|
|
||||||
<Input
|
|
||||||
placeholder='Connection must have a name'
|
|
||||||
defaultValue={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
variant='ontime-filled'
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
isDisabled={newName === clientName || !newName}
|
|
||||||
onClick={handleRename}
|
|
||||||
width='100%'
|
|
||||||
variant='ontime-filled'
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,37 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline {
|
.title-card__title,
|
||||||
display: flex;
|
.title-card__secondary {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__title {
|
.title-card__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
line-height: 1.1em;
|
line-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-card__secondary {
|
||||||
|
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||||
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__label {
|
.title-card__label {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
top: 0.5rem;
|
||||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||||
font-weight: 400;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -28,13 +40,3 @@
|
|||||||
color: var(--accent-color-override, $accent-color);
|
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,28 +1,32 @@
|
|||||||
|
import { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
|
|
||||||
import './TitleCard.scss';
|
import './TitleCard.scss';
|
||||||
|
|
||||||
interface TitleCardProps {
|
interface TitleCardProps {
|
||||||
label: 'now' | 'next';
|
|
||||||
title: string;
|
title: string;
|
||||||
|
label?: 'now' | 'next';
|
||||||
secondary?: string;
|
secondary?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TitleCard(props: TitleCardProps) {
|
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||||
const { label, title, secondary } = props;
|
const { label, title, secondary, className = '' } = props;
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
const accent = label === 'now';
|
const accent = label === 'now';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='title-card'>
|
<div className={`title-card ${className}`} ref={ref}>
|
||||||
<div className='inline'>
|
<span className='title-card__title'>{title}</span>
|
||||||
<span className='title-card__title'>{title}</span>
|
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
{label && getLocalizedString(`common.${label}`)}
|
||||||
{getLocalizedString(`common.${label}`)}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='title-card__secondary'>{secondary}</div>
|
<div className='title-card__secondary'>{secondary}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
TitleCard.displayName = 'TitleCard';
|
||||||
|
export default TitleCard;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
const { id, type, defaultValue } = paramField;
|
const { id, type, defaultValue } = paramField;
|
||||||
|
|
||||||
|
if (type === 'persist') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'option') {
|
if (type === 'option') {
|
||||||
const optionFromParams = searchParams.get(id);
|
const optionFromParams = searchParams.get(id);
|
||||||
const defaultOptionValue = optionFromParams || defaultValue;
|
const defaultOptionValue = optionFromParams || defaultValue;
|
||||||
|
|||||||
@@ -13,14 +13,24 @@
|
|||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.columnSection {
|
.section {
|
||||||
|
color: $ui-white;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldSet {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: $section-spacing 0;
|
padding: $section-spacing 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { ParamField } from './types';
|
import { isSection, ViewOption } from './types';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
|
|
||||||
@@ -22,25 +22,36 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
|||||||
/**
|
/**
|
||||||
* Makes a new URLSearchParams object from the given params object
|
* Makes a new URLSearchParams object from the given params object
|
||||||
*/
|
*/
|
||||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
|
||||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
const newSearchParams = new URLSearchParams();
|
||||||
acc[id] = String(defaultValue);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
// Convert paramFields to an object that contains default values
|
||||||
|
const defaultValues: Record<string, string> = {};
|
||||||
|
paramFields.forEach((option) => {
|
||||||
|
if (!isSection(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 && defaultValues[id] !== value) {
|
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||||
newSearchParams.set(id, value);
|
newSearchParams.set(id, value);
|
||||||
}
|
}
|
||||||
return newSearchParams;
|
});
|
||||||
}, new URLSearchParams());
|
return newSearchParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EditFormDrawerProps {
|
interface EditFormDrawerProps {
|
||||||
paramFields: ParamField[];
|
viewOptions: ViewOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
formEvent.preventDefault();
|
formEvent.preventDefault();
|
||||||
|
|
||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
@@ -85,15 +96,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||||
{paramFields.map((field) => (
|
{viewOptions.map((option) => {
|
||||||
<div key={field.title} className={style.columnSection}>
|
if (isSection(option)) {
|
||||||
<label className={style.label}>
|
return (
|
||||||
<span className={style.title}>{field.title}</span>
|
<div key={option.section} className={style.section}>
|
||||||
<span className={style.description}>{field.description}</span>
|
{option.section}
|
||||||
<ParamInput key={field.title} paramField={field} />
|
</div>
|
||||||
</label>
|
);
|
||||||
</div>
|
}
|
||||||
))}
|
|
||||||
|
if (option.type === 'persist') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={option.title} className={style.fieldSet}>
|
||||||
|
<label className={style.label}>
|
||||||
|
<span className={style.title}>{option.title}</span>
|
||||||
|
<span className={style.description}>{option.description}</span>
|
||||||
|
<ParamInput key={option.title} paramField={option} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</form>
|
</form>
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { type ParamField } from './types';
|
import type { ParamField } from './types';
|
||||||
|
|
||||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
export const makeOptionsFromCustomFields = (
|
||||||
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
|
customFields: CustomFields,
|
||||||
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
|
additionalOptions: Record<string, string> = {},
|
||||||
}, additionalOptions ?? {});
|
) => {
|
||||||
return customFieldOptions;
|
return Object.entries(customFields).reduce((options, [key, value]) => {
|
||||||
|
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||||
|
return options;
|
||||||
|
}, additionalOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimeOption = (timeFormat: string): ParamField => {
|
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||||
const placeholder = `${timeFormat} (default)`;
|
const placeholder = `${timeFormat} (default)`;
|
||||||
return {
|
return {
|
||||||
id: 'timeformat',
|
id: 'timeformat',
|
||||||
@@ -20,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideTimerSeconds: ParamField = {
|
export const hideTimerSeconds: ParamField = {
|
||||||
id: 'hideTimerSeconds',
|
id: 'hideTimerSeconds',
|
||||||
title: 'Hide seconds in timer',
|
title: 'Hide seconds in timer',
|
||||||
description: 'Whether to hide seconds in the running timer',
|
description: 'Whether to hide seconds in the running timer',
|
||||||
@@ -28,484 +31,10 @@ const hideTimerSeconds: ParamField = {
|
|||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLeadingZeros: ParamField = {
|
export const showLeadingZeros: ParamField = {
|
||||||
id: 'showLeadingZeros',
|
id: 'showLeadingZeros',
|
||||||
title: 'Show leading zeros in timer',
|
title: 'Show leading zeros in timer',
|
||||||
description: 'Whether to show leading zeros in the running timer',
|
description: 'Whether to show leading zeros in the running timer',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
{
|
|
||||||
id: 'key',
|
|
||||||
title: 'Key Colour',
|
|
||||||
description: 'Background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'text',
|
|
||||||
title: 'Text Colour',
|
|
||||||
description: 'Text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'textbg',
|
|
||||||
title: 'Text Background',
|
|
||||||
description: 'Colour of text background in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'font',
|
|
||||||
title: 'Font',
|
|
||||||
description: 'Font family, will use the fonts available in the system',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Arial Black (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'size',
|
|
||||||
title: 'Text Size',
|
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '1 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alignx',
|
|
||||||
title: 'Align Horizontal',
|
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsetx',
|
|
||||||
title: 'Offset Horizontal',
|
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'aligny',
|
|
||||||
title: 'Align Vertical',
|
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsety',
|
|
||||||
title: 'Offset Vertical',
|
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
|
||||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
|
||||||
return [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
hideTimerSeconds,
|
|
||||||
showLeadingZeros,
|
|
||||||
{
|
|
||||||
id: 'hideClock',
|
|
||||||
title: 'Hide Time Now',
|
|
||||||
description: 'Hides the Time Now field',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'main',
|
|
||||||
title: 'Main text',
|
|
||||||
description: 'Select the data source for the main text',
|
|
||||||
type: 'option',
|
|
||||||
values: mainOptions,
|
|
||||||
defaultValue: 'Title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary-src',
|
|
||||||
title: 'Secondary text',
|
|
||||||
description: 'Select the data source for the secondary text',
|
|
||||||
type: 'option',
|
|
||||||
values: secondaryOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideCards',
|
|
||||||
title: 'Hide Cards',
|
|
||||||
description: 'Hides the Now and Next cards',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideProgress',
|
|
||||||
title: 'Hide progress bar',
|
|
||||||
description: 'Hides the progress bar',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideMessage',
|
|
||||||
title: 'Hide Presenter Message',
|
|
||||||
description: 'Prevents the screen from displaying messages from the presenter',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideExternal',
|
|
||||||
title: 'Hide External',
|
|
||||||
description: 'Prevents the screen from displaying the external field',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|
||||||
hideTimerSeconds,
|
|
||||||
{
|
|
||||||
id: 'key',
|
|
||||||
title: 'Key Colour',
|
|
||||||
description: 'Background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'text',
|
|
||||||
title: 'Text Colour',
|
|
||||||
description: 'Text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'textbg',
|
|
||||||
title: 'Text Background',
|
|
||||||
description: 'Colour of text background in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'font',
|
|
||||||
title: 'Font',
|
|
||||||
description: 'Font family, will use the fonts available in the system',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Arial Black (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'size',
|
|
||||||
title: 'Text Size',
|
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '1 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alignx',
|
|
||||||
title: 'Align Horizontal',
|
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsetx',
|
|
||||||
title: 'Offset Horizontal',
|
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'aligny',
|
|
||||||
title: 'Align Vertical',
|
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
|
||||||
type: 'option',
|
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
|
||||||
defaultValue: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offsety',
|
|
||||||
title: 'Offset Vertical',
|
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '0 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideovertime',
|
|
||||||
title: 'Hide Overtime',
|
|
||||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideendmessage',
|
|
||||||
title: 'Hide End Message',
|
|
||||||
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
|
|
||||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
|
||||||
title: 'Title',
|
|
||||||
});
|
|
||||||
|
|
||||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
|
||||||
title: 'Title',
|
|
||||||
none: 'None',
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'trigger',
|
|
||||||
title: 'Animation Trigger',
|
|
||||||
description: '',
|
|
||||||
type: 'option',
|
|
||||||
values: {
|
|
||||||
event: 'Event Load',
|
|
||||||
manual: 'Manual',
|
|
||||||
},
|
|
||||||
defaultValue: 'manual',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'top-src',
|
|
||||||
title: 'Top Text',
|
|
||||||
description: '',
|
|
||||||
type: 'option',
|
|
||||||
values: topSourceOptions,
|
|
||||||
defaultValue: 'title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-src',
|
|
||||||
title: 'Bottom Text',
|
|
||||||
description: 'Select the data source for the bottom element',
|
|
||||||
type: 'option',
|
|
||||||
values: bottomSourceOptions,
|
|
||||||
defaultValue: 'none',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'top-colour',
|
|
||||||
title: 'Top Text Colour',
|
|
||||||
description: 'Top text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '0000ff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-colour',
|
|
||||||
title: 'Bottom Text Colour',
|
|
||||||
description: 'Bottom text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '0000ff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'top-bg',
|
|
||||||
title: 'Top Background Colour',
|
|
||||||
description: 'Top text background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-bg',
|
|
||||||
title: 'Bottom Background Colour',
|
|
||||||
description: 'Bottom text background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'top-size',
|
|
||||||
title: 'Top Text Size',
|
|
||||||
description: 'Font size of the top text',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '65px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-size',
|
|
||||||
title: 'Bottom Text Size',
|
|
||||||
description: 'Font size of the bottom text',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '64px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'width',
|
|
||||||
title: 'Minimum Width',
|
|
||||||
description: 'Minimum Width of the element',
|
|
||||||
type: 'number',
|
|
||||||
prefix: '%',
|
|
||||||
placeholder: '45 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'transition',
|
|
||||||
title: 'Transition',
|
|
||||||
description: 'Transition in time in seconds (default 3)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '3 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delay',
|
|
||||||
title: 'Delay',
|
|
||||||
description: 'Delay between transition in and out in seconds (default 3)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '3 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'key',
|
|
||||||
title: 'Key Colour',
|
|
||||||
description: 'Colour of the background',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'ffffffff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'line-colour',
|
|
||||||
title: 'Line Colour',
|
|
||||||
description: 'Colour of the line',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'ff0000ff (default)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
|
||||||
|
|
||||||
return [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
{
|
|
||||||
id: 'hidePast',
|
|
||||||
title: 'Hide past events',
|
|
||||||
description: 'Scheduler will only show upcoming events',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'stopCycle',
|
|
||||||
title: 'Stop cycling through event pages',
|
|
||||||
description: 'Schedule will not auto-cycle through events',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'eventsPerPage',
|
|
||||||
title: 'Events per page',
|
|
||||||
description: 'Sets the number of events on the page, can cause overflow',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '8 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary-src',
|
|
||||||
title: 'Event secondary text',
|
|
||||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
|
||||||
type: 'option',
|
|
||||||
values: secondaryOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
|
||||||
|
|
||||||
return [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
{
|
|
||||||
id: 'hidePast',
|
|
||||||
title: 'Hide past events',
|
|
||||||
description: 'Scheduler will only show upcoming events',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'stopCycle',
|
|
||||||
title: 'Stop cycling through event pages',
|
|
||||||
description: 'Schedule will not auto-cycle through events',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'eventsPerPage',
|
|
||||||
title: 'Events per page',
|
|
||||||
description: 'Sets the number of events on the page, can cause overflow',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '8 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary-src',
|
|
||||||
title: 'Event secondary text',
|
|
||||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
|
||||||
type: 'option',
|
|
||||||
values: secondaryOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
hideTimerSeconds,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
|
|
||||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
|
||||||
|
|
||||||
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
|
|
||||||
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return [
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
{
|
|
||||||
id: 'hidepast',
|
|
||||||
title: 'Hide Past Events',
|
|
||||||
description: 'Whether to events that have passed',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'main',
|
|
||||||
title: 'Main data field',
|
|
||||||
description: 'Field to be shown in the first line of text',
|
|
||||||
type: 'option',
|
|
||||||
values: fieldOptions,
|
|
||||||
defaultValue: 'title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary',
|
|
||||||
title: 'Secondary data field',
|
|
||||||
description: 'Field to be shown in the second line of text',
|
|
||||||
type: 'option',
|
|
||||||
values: fieldOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subscribe',
|
|
||||||
title: 'Highlight Field',
|
|
||||||
description: 'Choose a custom field to highlight',
|
|
||||||
type: 'multi-option',
|
|
||||||
values: customFieldSelect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'shouldEdit',
|
|
||||||
title: 'Edit custom field',
|
|
||||||
description: 'Allows editing an events selected custom field by long pressing.',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
type ParamSection = {
|
||||||
|
section: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseField = {
|
type BaseField = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,5 +23,15 @@ type MultiOptionsField = {
|
|||||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||||
|
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||||
|
|
||||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
|
export type ParamField = BaseField &
|
||||||
|
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
||||||
|
export type ViewOption = ParamSection | ParamField;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type assertion utility checks whether an entry is a section separator
|
||||||
|
*/
|
||||||
|
export function isSection(entry: ViewOption): entry is ParamSection {
|
||||||
|
return 'section' in entry;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useClientStore } from '../stores/clientStore';
|
||||||
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const useClientPath = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const redirect = useClientStore((store) => store.redirect);
|
||||||
|
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||||
|
|
||||||
|
// notify of client path changes
|
||||||
|
useEffect(() => {
|
||||||
|
//remove leading '/' from path
|
||||||
|
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
|
||||||
|
socketSendJson('set-client-path', fullPath);
|
||||||
|
}, [pathname, search]);
|
||||||
|
|
||||||
|
// navigate to new path when received from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirect === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear redirect
|
||||||
|
setRedirect('');
|
||||||
|
|
||||||
|
// navigate if there is a path change
|
||||||
|
if (redirect !== pathname + search) {
|
||||||
|
navigate(redirect);
|
||||||
|
}
|
||||||
|
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
|
|||||||
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||||
const isLongPressActive = useRef(false);
|
const isLongPressActive = useRef(false);
|
||||||
const isPressed = useRef(false);
|
const isPressed = useRef(false);
|
||||||
const timerId = useRef<NodeJS.Timer>();
|
const timerId = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const start = (event: SyntheticEvent) => {
|
const start = (event: SyntheticEvent) => {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
|||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
export const setClientRemote = {
|
||||||
|
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||||
|
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||||
|
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||||
|
};
|
||||||
|
|
||||||
export const useRundownEditor = () => {
|
export const useRundownEditor = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { ClientList } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ClientStore {
|
||||||
|
name?: string;
|
||||||
|
setName: (newValue: string) => void;
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
setId: (newValue: string) => void;
|
||||||
|
|
||||||
|
redirect: string;
|
||||||
|
setRedirect: (newValue: string) => void;
|
||||||
|
|
||||||
|
clients: ClientList;
|
||||||
|
setClients: (clients: ClientList) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientNameKey = 'ontime-client-name';
|
||||||
|
|
||||||
|
function persistNameInStorage(newValue: string) {
|
||||||
|
localStorage.setItem(clientNameKey, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClientStore = create<ClientStore>((set) => ({
|
||||||
|
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||||
|
setName: (name: string) =>
|
||||||
|
set(() => {
|
||||||
|
persistNameInStorage(name);
|
||||||
|
return { name };
|
||||||
|
}),
|
||||||
|
|
||||||
|
id: '',
|
||||||
|
setId: (id) => set({ id }),
|
||||||
|
|
||||||
|
redirect: '',
|
||||||
|
setRedirect: (redirect: string) => set({ redirect }),
|
||||||
|
|
||||||
|
clients: {},
|
||||||
|
setClients: (clients: ClientList) => set({ clients }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientName(): string | undefined {
|
||||||
|
return useClientStore.getState().name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientName(name: string): void {
|
||||||
|
useClientStore.getState().setName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating current client name (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientId(id: string): void {
|
||||||
|
useClientStore.getState().setId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows getting client name (outside react)
|
||||||
|
*/
|
||||||
|
export function getClientId(): string | undefined {
|
||||||
|
return useClientStore.getState().id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows updating redirect (outside react)
|
||||||
|
*/
|
||||||
|
export function setClientRedirect(path: string): void {
|
||||||
|
useClientStore.getState().setRedirect(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting clients (outside react)
|
||||||
|
*/
|
||||||
|
export function setClients(clients: ClientList): void {
|
||||||
|
return useClientStore.getState().setClients(clients);
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useStore } from 'zustand';
|
|
||||||
import { createStore } from 'zustand/vanilla';
|
|
||||||
|
|
||||||
interface SocketClientNameState {
|
|
||||||
name?: string;
|
|
||||||
setName: (newValue: string) => void;
|
|
||||||
persistName: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientNameKey = 'ontime-client-name';
|
|
||||||
|
|
||||||
function persistKeyToStorage(newValue: string) {
|
|
||||||
localStorage.setItem(clientNameKey, newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
|
||||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
|
||||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
|
||||||
persistName: (newValue: string) =>
|
|
||||||
set(() => {
|
|
||||||
persistKeyToStorage(newValue);
|
|
||||||
return { name: newValue };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useSocketClientName = () => useStore(socketClientName);
|
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import { Log, RuntimeStore } from 'ontime-types';
|
import { Log, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { socketClientName } from '../stores/connectionName';
|
import {
|
||||||
|
getClientId,
|
||||||
|
getClientName,
|
||||||
|
setClientId,
|
||||||
|
setClientName,
|
||||||
|
setClientRedirect,
|
||||||
|
setClients,
|
||||||
|
} from '../stores/clientStore';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||||
|
|
||||||
export let websocket: WebSocket | null = null;
|
export let websocket: WebSocket | null = null;
|
||||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
const reconnectInterval = 1000;
|
const reconnectInterval = 1000;
|
||||||
|
|
||||||
export let shouldReconnect = true;
|
export let shouldReconnect = true;
|
||||||
export let hasConnected = false;
|
export let hasConnected = false;
|
||||||
export let reconnectAttempts = 0;
|
export let reconnectAttempts = 0;
|
||||||
|
|
||||||
export const connectSocket = (preferredClientName?: string) => {
|
export const connectSocket = () => {
|
||||||
websocket = new WebSocket(websocketUrl);
|
websocket = new WebSocket(websocketUrl);
|
||||||
|
|
||||||
|
const preferredClientName = getClientName();
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
if (preferredClientName) {
|
if (preferredClientName) {
|
||||||
socketSendJson('set-client-name', preferredClientName);
|
socketSendJson('set-client-name', preferredClientName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socketSendJson('set-client-type', 'ontime');
|
||||||
|
socketSendJson('set-client-path', location.pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'client-name': {
|
case 'client-id': {
|
||||||
socketClientName.getState().setName(payload);
|
if (typeof payload === 'string') {
|
||||||
|
setClientId(payload);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'client-name': {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
setClientName(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-rename': {
|
||||||
|
if (typeof payload === 'object') {
|
||||||
|
const id = getClientId();
|
||||||
|
if (payload.target && payload.target === id) {
|
||||||
|
setClientName(payload.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-redirect': {
|
||||||
|
if (typeof payload === 'object') {
|
||||||
|
const id = getClientId();
|
||||||
|
if (payload.target && payload.target === id) {
|
||||||
|
setClientRedirect(payload.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client-list': {
|
||||||
|
setClients(payload);
|
||||||
|
if (!isProduction) {
|
||||||
|
ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ontime-log': {
|
case 'ontime-log': {
|
||||||
addLog(payload as Log);
|
addLog(payload as Log);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
|
|||||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||||
|
|
||||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||||
|
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
|
||||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||||
import LogPanel from './panel/log-panel/LogPanel';
|
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
|
||||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||||
@@ -30,8 +31,9 @@ export default function AppSettings() {
|
|||||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||||
{panel === 'sources' && <SourcesPanel />}
|
{panel === 'sources' && <SourcesPanel />}
|
||||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||||
|
{panel === 'client_control' && <ClientControlPanel />}
|
||||||
{panel === 'about' && <AboutPanel />}
|
{panel === 'about' && <AboutPanel />}
|
||||||
{panel === 'log' && <LogPanel />}
|
{panel === 'network' && <NetworkLogPanel location={location} />}
|
||||||
{panel === 'shutdown' && <ShutdownPanel />}
|
{panel === 'shutdown' && <ShutdownPanel />}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-shadow: 0 1px $white-10;
|
box-shadow: 0 1px $white-10;
|
||||||
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: $gray-400;
|
color: $gray-400;
|
||||||
background-color: $gray-1350;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: $element-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.halfWidth {
|
||||||
|
width: 50%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathList {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeList {
|
||||||
|
width: 100%;
|
||||||
|
* {
|
||||||
|
margin-right: $element-spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import ClientList from './ClientList';
|
||||||
|
|
||||||
|
export default function ClientControlPanel() {
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Manage clients</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<ClientList />
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||||
|
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||||
|
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||||
|
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import style from './ClientControlPanel.module.scss';
|
||||||
|
|
||||||
|
export default function ClientList() {
|
||||||
|
const id = useClientStore((store) => store.id);
|
||||||
|
const clients = useClientStore((store) => store.clients);
|
||||||
|
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
|
||||||
|
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
|
||||||
|
const { setIdentify } = setClientRemote;
|
||||||
|
|
||||||
|
const [targetId, setTargetId] = useState('');
|
||||||
|
|
||||||
|
const openRename = (targetId: string) => {
|
||||||
|
setTargetId(targetId);
|
||||||
|
onOpenRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRedirect = (targetId: string) => {
|
||||||
|
setTargetId(targetId);
|
||||||
|
onOpenRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||||
|
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||||
|
|
||||||
|
const targetClient = clients[targetId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpenRedirect && (
|
||||||
|
<RedirectClientModal
|
||||||
|
id={targetId}
|
||||||
|
name={targetClient?.name}
|
||||||
|
path={targetClient?.path}
|
||||||
|
isOpen={isOpenRedirect}
|
||||||
|
onClose={onCloseRedirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOpenRename && (
|
||||||
|
<RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
|
)}
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td className={style.fullWidth}>Client Name (Connection ID)</td>
|
||||||
|
<td className={style.fullWidth}>Path</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ontimeClients.map(([key, client]) => {
|
||||||
|
const { identify, name, path } = client;
|
||||||
|
const isCurrent = id === key;
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className={style.badgeList}>
|
||||||
|
<Badge variant='outline' size='xs'>
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
{isCurrent && (
|
||||||
|
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||||
|
self
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
||||||
|
<td className={style.actionButtons}>
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
className={`${identify ? style.blink : ''}`}
|
||||||
|
isDisabled={isCurrent}
|
||||||
|
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||||
|
onClick={() => {
|
||||||
|
setIdentify({ target: key, identify: !identify });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Identify
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-subtle'
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||||
|
onClick={() => openRename(key)}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-subtle'
|
||||||
|
isDisabled={isCurrent}
|
||||||
|
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||||
|
onClick={() => openRedirect(key)}
|
||||||
|
>
|
||||||
|
Redirect
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||||
|
<td className={style.halfWidth}>Client type</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{otherClients.map(([key, client]) => {
|
||||||
|
const { name, type } = client;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className={style.badgeList}>
|
||||||
|
<Badge variant='outline' size='sx'>
|
||||||
|
{key}
|
||||||
|
</Badge>
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td>{type}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -153,6 +153,7 @@ export default function GeneralPanelForm() {
|
|||||||
<option value='pt'>Portuguese</option>
|
<option value='pt'>Portuguese</option>
|
||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
|
<option value='pl'>Polish</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ export const cycles: CycleLabel[] = [
|
|||||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
{ id: 5, label: 'Every second', value: 'onClock' },
|
||||||
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
|
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
|
||||||
{ id: 6, label: 'On Finish', value: 'onFinish' },
|
{ id: 6, label: 'On Finish', value: 'onFinish' },
|
||||||
|
{ id: 7, label: 'On Warning', value: 'onWarning' },
|
||||||
|
{ id: 8, label: 'On Danger', value: 'onDanger' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import * as Panel from '../PanelUtils';
|
|
||||||
|
|
||||||
import LogExport from './LogExport';
|
|
||||||
import InfoNif from './NetworkInterfaces';
|
|
||||||
|
|
||||||
export default function LogPanel() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Panel.Header>Log</Panel.Header>
|
|
||||||
<Panel.Section>
|
|
||||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
|
||||||
</Panel.Section>
|
|
||||||
<InfoNif />
|
|
||||||
<LogExport />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+2
-2
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
|
|||||||
import Log from '../../../log/Log';
|
import Log from '../../../log/Log';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import style from './LogExport.module.scss';
|
import style from './NetworkLogExport.module.scss';
|
||||||
|
|
||||||
export default function LogExport() {
|
export default function LogExport() {
|
||||||
const extract = (event: MouseEvent) => {
|
const extract = (event: MouseEvent) => {
|
||||||
@@ -17,7 +17,7 @@ export default function LogExport() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Network log
|
Event log
|
||||||
<Button
|
<Button
|
||||||
variant='ontime-subtle'
|
variant='ontime-subtle'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
|
import { PanelBaseProps } from '../../settingsStore';
|
||||||
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import InfoNif from './NetworkInterfaces';
|
||||||
|
import LogExport from './NetworkLogExport';
|
||||||
|
|
||||||
|
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||||
|
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||||
|
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel.Header>Network</Panel.Header>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||||
|
</Panel.Section>
|
||||||
|
<InfoNif />
|
||||||
|
<div ref={logRef}>
|
||||||
|
<LogExport />
|
||||||
|
</div>
|
||||||
|
<div ref={clientsRef}>
|
||||||
|
<ClientControlPanel />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
import * as Panel from '../PanelUtils';
|
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export type ProjectFormValues = {
|
export type ProjectFormValues = {
|
||||||
@@ -15,10 +13,9 @@ interface ProjectFormProps {
|
|||||||
filename: string;
|
filename: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (values: ProjectFormValues) => Promise<void>;
|
onSubmit: (values: ProjectFormValues) => Promise<void>;
|
||||||
submitError: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
|
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
|
|||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
<Input
|
||||||
<Input
|
className={style.formInput}
|
||||||
className={style.formInput}
|
id='filename'
|
||||||
id='filename'
|
size='sm'
|
||||||
|
type='text'
|
||||||
|
variant='ontime-filled'
|
||||||
|
placeholder='Enter new name'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('filename', { required: true })}
|
||||||
|
/>
|
||||||
|
<div className={style.actionButtons}>
|
||||||
|
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
type='text'
|
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
placeholder='Enter new name'
|
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||||
autoComplete='off'
|
type='submit'
|
||||||
{...register('filename')}
|
className={style.saveButton}
|
||||||
/>
|
>
|
||||||
<div className={style.actionButtons}>
|
{action}
|
||||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
</Button>
|
||||||
Cancel
|
</div>
|
||||||
</Button>
|
</form>
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='ontime-filled'
|
|
||||||
isDisabled={!isDirty || !isValid || isSubmitting}
|
|
||||||
type='submit'
|
|
||||||
className={style.saveButton}
|
|
||||||
>
|
|
||||||
{action}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
renameProject,
|
renameProject,
|
||||||
} from '../../../../common/api/db';
|
} from '../../../../common/api/db';
|
||||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||||
|
|
||||||
@@ -40,36 +41,53 @@ export default function ProjectListItem({
|
|||||||
onToggleEditMode,
|
onToggleEditMode,
|
||||||
}: ProjectListItemProps) {
|
}: ProjectListItemProps) {
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmitRename = async (values: ProjectFormValues) => {
|
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
|
||||||
try {
|
return async (values: ProjectFormValues) => {
|
||||||
|
setLoading(true);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
if (!values.filename) {
|
if (!values.filename) {
|
||||||
setSubmitError('Filename cannot be blank');
|
setSubmitError('Filename cannot be blank');
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
const action = actionType === 'rename' ? renameProject : duplicateProject;
|
||||||
|
await action(filename, values.filename);
|
||||||
|
await onRefetch();
|
||||||
|
onSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
await renameProject(filename, values.filename);
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = async (filename: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
await loadProject(filename);
|
||||||
await onRefetch();
|
await onRefetch();
|
||||||
onSubmit();
|
await invalidateAllCaches();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(maybeAxiosError(error));
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
|
const handleDelete = async (filename: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setSubmitError(null);
|
||||||
try {
|
try {
|
||||||
setSubmitError(null);
|
await deleteProject(filename);
|
||||||
|
|
||||||
if (!values.filename) {
|
|
||||||
setSubmitError('Filename cannot be blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await duplicateProject(filename, values.filename);
|
|
||||||
await onRefetch();
|
await onRefetch();
|
||||||
onSubmit();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(maybeAxiosError(error));
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,50 +104,55 @@ export default function ProjectListItem({
|
|||||||
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={filename} className={classes}>
|
<>
|
||||||
{isCurrentlyBeingEdited ? (
|
{submitError && (
|
||||||
<td colSpan={99}>
|
<tr key='filename-error'>
|
||||||
<ProjectForm
|
<td colSpan={99}>
|
||||||
action={editingMode}
|
<Panel.Error>{submitError}</Panel.Error>
|
||||||
filename={filename}
|
</td>
|
||||||
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
|
</tr>
|
||||||
onCancel={handleCancel}
|
)}
|
||||||
submitError={submitError}
|
<tr key={filename} className={classes}>
|
||||||
/>
|
{isCurrentlyBeingEdited ? (
|
||||||
</td>
|
<td colSpan={99}>
|
||||||
) : (
|
<ProjectForm
|
||||||
<>
|
action={editingMode}
|
||||||
<td className={style.containCell}>{filename}</td>
|
|
||||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
|
||||||
<td className={style.actionButton}>
|
|
||||||
<ActionMenu
|
|
||||||
current={current}
|
|
||||||
filename={filename}
|
filename={filename}
|
||||||
onChangeEditMode={handleToggleEditMode}
|
onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
|
||||||
onRefetch={onRefetch}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</tr>
|
<td className={style.containCell}>{filename}</td>
|
||||||
|
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||||
|
<td className={style.actionButton}>
|
||||||
|
<ActionMenu
|
||||||
|
current={current}
|
||||||
|
filename={filename}
|
||||||
|
onChangeEditMode={handleToggleEditMode}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
isDisabled={loading}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionMenu({
|
interface ActionMenuProps {
|
||||||
current,
|
|
||||||
filename,
|
|
||||||
onChangeEditMode,
|
|
||||||
onRefetch,
|
|
||||||
}: {
|
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
isDisabled: boolean;
|
||||||
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
||||||
onRefetch: () => Promise<void>;
|
onDelete: (filename: string) => void;
|
||||||
}) {
|
onLoad: (filename: string) => void;
|
||||||
const handleLoad = async () => {
|
}
|
||||||
await loadProject(filename);
|
function ActionMenu(props: ActionMenuProps) {
|
||||||
await invalidateAllCaches();
|
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = () => {
|
const handleRename = () => {
|
||||||
onChangeEditMode('rename', filename);
|
onChangeEditMode('rename', filename);
|
||||||
@@ -139,11 +162,6 @@ function ActionMenu({
|
|||||||
onChangeEditMode('duplicate', filename);
|
onChangeEditMode('duplicate', filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
await deleteProject(filename);
|
|
||||||
await onRefetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
await downloadProject(filename);
|
await downloadProject(filename);
|
||||||
};
|
};
|
||||||
@@ -161,16 +179,17 @@ function ActionMenu({
|
|||||||
color='#e2e2e2' // $gray-200
|
color='#e2e2e2' // $gray-200
|
||||||
variant='ontime-ghosted'
|
variant='ontime-ghosted'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
isDisabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuItem onClick={handleLoad} isDisabled={current}>
|
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
|
||||||
Load
|
Load
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||||
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||||
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
||||||
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
|
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
|
||||||
<MenuItem isDisabled={current} onClick={handleDelete}>
|
<MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
|
||||||
Delete
|
Delete
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
|
|||||||
@@ -50,7 +50,21 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
|||||||
{ id: 'integrations__http', label: 'HTTP settings' },
|
{ id: 'integrations__http', label: 'HTTP settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'log', label: 'Log', split: true },
|
{
|
||||||
|
id: 'network',
|
||||||
|
label: 'Network',
|
||||||
|
split: true,
|
||||||
|
secondary: [
|
||||||
|
{
|
||||||
|
id: 'network__log',
|
||||||
|
label: 'Event log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network__clients',
|
||||||
|
label: 'Manage clients',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background-color: $gray-1300;
|
|
||||||
|
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
font-weight: 400;
|
|
||||||
|
th {
|
||||||
|
background-color: $gray-1300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventRow {
|
.eventRow {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import Color from 'color';
|
import Color from 'color';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
@@ -13,7 +12,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
|
|||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||||
import { initialColumnOrder } from './cuesheetCols';
|
import useColumnManager from './useColumnManager';
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
@@ -27,23 +26,20 @@ interface CuesheetProps {
|
|||||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
||||||
|
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
const {
|
||||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
columnVisibility,
|
||||||
key: 'table-order',
|
columnOrder,
|
||||||
defaultValue: initialColumnOrder,
|
columnSizing,
|
||||||
});
|
resetColumnOrder,
|
||||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
setColumnVisibility,
|
||||||
|
saveColumnOrder,
|
||||||
|
setColumnSizing,
|
||||||
|
} = useColumnManager(columns);
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||||
|
|
||||||
// keep column order in sync with columns
|
|
||||||
useEffect(() => {
|
|
||||||
const order = columns.map((col) => col.id as string);
|
|
||||||
saveColumnOrder(order);
|
|
||||||
}, [columns, saveColumnOrder]);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
@@ -61,10 +57,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetColumnOrder = () => {
|
|
||||||
saveColumnOrder(initialColumnOrder);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setAllVisible = () => {
|
const setAllVisible = () => {
|
||||||
table.toggleAllColumnsVisible(true);
|
table.toggleAllColumnsVisible(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,5 +117,3 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
...dynamicCustomFields,
|
...dynamicCustomFields,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
|
||||||
|
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||||
|
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||||
|
key: 'table-order',
|
||||||
|
defaultValue: columns.map((col) => col.id as string),
|
||||||
|
});
|
||||||
|
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||||
|
|
||||||
|
// if the columns change, we update the dataset
|
||||||
|
useEffect(() => {
|
||||||
|
let shouldReplace = false;
|
||||||
|
const newColumns: string[] = [];
|
||||||
|
|
||||||
|
// iterate through columns to see if there are new ids
|
||||||
|
columns.forEach((column) => {
|
||||||
|
const columnnId = column.id as string;
|
||||||
|
if (!shouldReplace && !columnOrder.includes(columnnId)) {
|
||||||
|
shouldReplace = true;
|
||||||
|
}
|
||||||
|
newColumns.push(columnnId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldReplace) {
|
||||||
|
saveColumnOrder(newColumns);
|
||||||
|
}
|
||||||
|
}, [columnOrder, columns, saveColumnOrder]);
|
||||||
|
|
||||||
|
const resetColumnOrder = useCallback(() => {
|
||||||
|
saveColumnOrder(columns.map((col) => col.id as string));
|
||||||
|
}, [columns, saveColumnOrder]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnVisibility,
|
||||||
|
columnOrder,
|
||||||
|
columnSizing,
|
||||||
|
resetColumnOrder,
|
||||||
|
setColumnVisibility,
|
||||||
|
saveColumnOrder,
|
||||||
|
setColumnSizing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
|||||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||||
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useOperator } from '../../common/hooks/useSocket';
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
@@ -22,6 +21,7 @@ import FollowButton from './follow-button/FollowButton';
|
|||||||
import OperatorBlock from './operator-block/OperatorBlock';
|
import OperatorBlock from './operator-block/OperatorBlock';
|
||||||
import OperatorEvent from './operator-event/OperatorEvent';
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
import StatusBar from './status-bar/StatusBar';
|
import StatusBar from './status-bar/StatusBar';
|
||||||
|
import { getOperatorOptions } from './operator.options';
|
||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export default function Operator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer}>
|
<div className={style.operatorContainer}>
|
||||||
<ViewParamsEditor paramFields={operatorOptions} />
|
<ViewParamsEditor viewOptions={operatorOptions} />
|
||||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||||
|
|
||||||
<StatusBar
|
<StatusBar
|
||||||
|
|||||||
@@ -65,12 +65,15 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|
||||||
|
@include ellipsis-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryField {
|
.secondaryField {
|
||||||
grid-area: secondary;
|
grid-area: secondary;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
@include ellipsis-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule {
|
.schedule {
|
||||||
@@ -96,10 +99,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.noColour {
|
.noColour {
|
||||||
@@ -110,6 +113,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
background-color: $gray-1250;
|
||||||
color: var(--operator-highlight-override, $ui-white);
|
color: var(--operator-highlight-override, $ui-white);
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||||
|
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||||
|
|
||||||
|
const customFieldSelect = Object.entries(customFields).reduce<
|
||||||
|
Record<string, { value: string; label: string; colour: string }>
|
||||||
|
>((acc, [key, field]) => {
|
||||||
|
acc[key] = { value: key, label: field.label, colour: field.colour };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
title: 'Main data field',
|
||||||
|
description: 'Field to be shown in the first line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: fieldOptions,
|
||||||
|
defaultValue: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary',
|
||||||
|
title: 'Secondary data field',
|
||||||
|
description: 'Field to be shown in the second line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: fieldOptions,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subscribe',
|
||||||
|
title: 'Highlight Field',
|
||||||
|
description: 'Choose a custom field to highlight',
|
||||||
|
type: 'multi-option',
|
||||||
|
values: customFieldSelect,
|
||||||
|
},
|
||||||
|
{ section: 'Element visibility' },
|
||||||
|
{
|
||||||
|
id: 'hidepast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shouldEdit',
|
||||||
|
title: 'Edit custom field',
|
||||||
|
description: 'Allows editing an events selected custom field by long pressing.',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
|
|||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
interface CountedTextInputProps extends InputProps {
|
interface EventTextInputProps extends InputProps {
|
||||||
field: EditorUpdateFields;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventTextInput(props: CountedTextInputProps) {
|
export default function EventTextInput(props: EventTextInputProps) {
|
||||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
const { field, label, initialValue, submitHandler, maxLength } = props;
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||||
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
data-testid='input-textfield'
|
data-testid='input-textfield'
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={maxLength || 50}
|
maxLength={maxLength || 100}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
ProjectData,
|
ProjectData,
|
||||||
|
Runtime,
|
||||||
Settings,
|
Settings,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
TimerMessage,
|
TimerMessage,
|
||||||
@@ -36,6 +37,7 @@ type WithDataProps = {
|
|||||||
publicEventNext: OntimeEvent | null;
|
publicEventNext: OntimeEvent | null;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
|
runtime: Runtime;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
}, [rundownData]);
|
}, [rundownData]);
|
||||||
|
|
||||||
// websocket data
|
// websocket data
|
||||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } =
|
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
|
||||||
useStore(runtimeStore);
|
useStore(runtimeStore);
|
||||||
const publicSelectedId = publicEventNow?.id ?? null;
|
const publicSelectedId = publicEventNow?.id ?? null;
|
||||||
const selectedId = eventNow?.id ?? null;
|
const selectedId = eventNow?.id ?? null;
|
||||||
@@ -108,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
publicEventNext={publicEventNext}
|
publicEventNext={publicEventNext}
|
||||||
publicEventNow={publicEventNow}
|
publicEventNow={publicEventNow}
|
||||||
publicSelectedId={publicSelectedId}
|
publicSelectedId={publicSelectedId}
|
||||||
|
runtime={runtime}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
time={TimeManagerType}
|
time={TimeManagerType}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -23,8 +22,12 @@ import { titleVariants } from '../common/animation';
|
|||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { getBackstageOptions } from './backstage.options';
|
||||||
|
|
||||||
import './Backstage.scss';
|
import './Backstage.scss';
|
||||||
|
|
||||||
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
@@ -97,7 +100,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||||
<ViewParamsEditor paramFields={backstageOptions} />
|
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
@@ -124,7 +127,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
>
|
||||||
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
|
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
|
||||||
<div className='timer-group'>
|
<div className='timer-group'>
|
||||||
<div className='aux-timers'>
|
<div className='aux-timers'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||||
@@ -151,16 +154,17 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{eventNext && (
|
{eventNext && (
|
||||||
<motion.div
|
<MotionTitleCard
|
||||||
className='event next'
|
className='event next'
|
||||||
key='next'
|
key='next'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
label='next'
|
||||||
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
|
title={eventNext.title}
|
||||||
</motion.div>
|
secondary={secondaryTextNext}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
{
|
||||||
|
id: 'secondary-src',
|
||||||
|
title: 'Event secondary text',
|
||||||
|
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||||
|
type: 'option',
|
||||||
|
values: secondaryOptions,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{ section: 'Schedule options' },
|
||||||
|
{
|
||||||
|
id: 'eventsPerPage',
|
||||||
|
title: 'Events per page',
|
||||||
|
description: 'Sets the number of events on the page, can cause overflow',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '8 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide past events',
|
||||||
|
description: 'Scheduler will only show upcoming events',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stopCycle',
|
||||||
|
title: 'Stop cycling through event pages',
|
||||||
|
description: 'Schedule will not auto-cycle through events',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { Settings, ViewSettings } from 'ontime-types';
|
import { Settings, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
|
|||||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
|
|
||||||
|
import { getClockOptions } from './clock.options';
|
||||||
|
|
||||||
import './Clock.scss';
|
import './Clock.scss';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='clock-view'
|
data-testid='clock-view'
|
||||||
>
|
>
|
||||||
<ViewParamsEditor paramFields={clockOptions} />
|
<ViewParamsEditor viewOptions={clockOptions} />
|
||||||
<SuperscriptTime
|
<SuperscriptTime
|
||||||
time={clock}
|
time={clock}
|
||||||
className='clock'
|
className='clock'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'View style override' },
|
||||||
|
{
|
||||||
|
id: 'key',
|
||||||
|
title: 'Key Colour',
|
||||||
|
description: 'Background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text',
|
||||||
|
title: 'Text Colour',
|
||||||
|
description: 'Text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'fffff (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'textbg',
|
||||||
|
title: 'Text Background',
|
||||||
|
description: 'Colour of text background in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'font',
|
||||||
|
title: 'Font',
|
||||||
|
description: 'Font family, will use the fonts available in the system',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'Arial Black (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size',
|
||||||
|
title: 'Text Size',
|
||||||
|
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '1 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alignx',
|
||||||
|
title: 'Align Horizontal',
|
||||||
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
|
type: 'option',
|
||||||
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'offsetx',
|
||||||
|
title: 'Offset Horizontal',
|
||||||
|
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aligny',
|
||||||
|
title: 'Align Vertical',
|
||||||
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
|
type: 'option',
|
||||||
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'offsety',
|
||||||
|
title: 'Offset Vertical',
|
||||||
|
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -3,6 +3,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero
|
|||||||
|
|
||||||
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||||
|
|
||||||
@@ -68,11 +69,13 @@ export function getFormattedTimer(
|
|||||||
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
|
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeToParse = timer;
|
if (timerType === TimerType.Clock) {
|
||||||
|
return formatTime(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeToParse = timer;
|
||||||
if (options.removeSeconds) {
|
if (options.removeSeconds) {
|
||||||
const timerIsTimeOfDay = timerType === TimerType.Clock;
|
const isNegative = timeToParse < -MILLIS_PER_SECOND && timerType !== TimerType.CountUp;
|
||||||
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
|
|
||||||
if (isNegative) {
|
if (isNegative) {
|
||||||
// in negative numbers, we need to round down
|
// in negative numbers, we need to round down
|
||||||
timeToParse -= MILLIS_PER_MINUTE;
|
timeToParse -= MILLIS_PER_MINUTE;
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: clamp(24px, 2vw, 32px);
|
font-size: clamp(1.5rem, 2vw, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__events {
|
&__events {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 70vh;
|
height: 70vh;
|
||||||
@@ -38,18 +38,18 @@
|
|||||||
.countdown-container {
|
.countdown-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: min(2vh, 16px);
|
gap: min(2vh, 1rem);
|
||||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto auto 1fr;
|
grid-template-rows: auto auto auto auto 1fr;
|
||||||
grid-template-columns: 100%;
|
grid-template-columns: 100%;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'header'
|
'header'
|
||||||
'status'
|
'status'
|
||||||
'clock'
|
'clock'
|
||||||
'title'
|
'title'
|
||||||
'timers';
|
'timers';
|
||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
|
|
||||||
@@ -59,13 +59,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $timer-label-size;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: $timer-value-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
line-height: 0.95em;
|
line-height: 0.95em;
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
.title {
|
.title {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: 16px 24px;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -119,28 +119,49 @@
|
|||||||
|
|
||||||
.timer-group {
|
.timer-group {
|
||||||
grid-area: timers;
|
grid-area: timers;
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-evenly;
|
grid-template-areas:
|
||||||
align-items: flex-end;
|
'projected-start projected-end'
|
||||||
|
'scheduled-start scheduled-end';
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
row-gap: clamp(1rem, 5vh, 4rem);
|
||||||
|
|
||||||
|
align-self: flex-end;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
.aux-timers {
|
&__projected-start {
|
||||||
text-align: center;
|
grid-area: projected-start;
|
||||||
font-size: clamp(24px, 1.75vw, 32px);
|
}
|
||||||
|
|
||||||
&__label {
|
&__projected-end {
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
grid-area: projected-end;
|
||||||
text-transform: uppercase;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__value {
|
&__scheduled-start {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
grid-area: scheduled-start;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
}
|
||||||
letter-spacing: 0.05em;
|
|
||||||
line-height: 0.95em;
|
|
||||||
|
|
||||||
&--delayed {
|
&__scheduled-end {
|
||||||
color: $delay-color;
|
grid-area: scheduled-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: $timer-label-size;
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: $timer-value-size;
|
||||||
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 0.95em;
|
||||||
|
|
||||||
|
&--delayed {
|
||||||
|
color: $delay-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
import {
|
||||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
OntimeEvent,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
Playback,
|
||||||
|
Runtime,
|
||||||
|
Settings,
|
||||||
|
SupportedEvent,
|
||||||
|
TimerPhase,
|
||||||
|
ViewSettings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -12,8 +19,10 @@ import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
|||||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
|
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
|
||||||
|
|
||||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
|
||||||
|
import { getCountdownOptions } from './countdown.options';
|
||||||
import CountdownSelect from './CountdownSelect';
|
import CountdownSelect from './CountdownSelect';
|
||||||
|
|
||||||
import './Countdown.scss';
|
import './Countdown.scss';
|
||||||
@@ -21,27 +30,26 @@ import './Countdown.scss';
|
|||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
time: ViewExtendedTimer;
|
runtime: Runtime;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
export default function Countdown(props: CountdownProps) {
|
||||||
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props;
|
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||||
const [runningTimer, setRunningTimer] = useState(0);
|
|
||||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
|
||||||
const [delay, setDelay] = useState(0);
|
const [delay, setDelay] = useState(0);
|
||||||
|
|
||||||
useWindowTitle('Countdown');
|
useWindowTitle('Countdown');
|
||||||
|
|
||||||
// eg. http://localhost:4001/countdown?eventId=ei0us
|
// eg. http://localhost:4001/countdown?eventId=ei0us
|
||||||
// Check for user options
|
// update data to the event we are following
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!backstageEvents) {
|
if (!backstageEvents) {
|
||||||
return;
|
return;
|
||||||
@@ -50,6 +58,12 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
const eventId = searchParams.get('eventid');
|
const eventId = searchParams.get('eventid');
|
||||||
const eventIndex = searchParams.get('event');
|
const eventIndex = searchParams.get('event');
|
||||||
|
|
||||||
|
// if there is no event selected, we reset the data
|
||||||
|
if (!eventId && !eventIndex) {
|
||||||
|
setFollow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let followThis: OntimeEvent | null = null;
|
let followThis: OntimeEvent | null = null;
|
||||||
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
|
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
|
||||||
|
|
||||||
@@ -66,49 +80,50 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
}
|
}
|
||||||
}, [backstageEvents, searchParams]);
|
}, [backstageEvents, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!follow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { message, timer } = fetchTimerData(time, follow, selectedId);
|
|
||||||
setRunningMessage(message);
|
|
||||||
setRunningTimer(timer);
|
|
||||||
}, [follow, selectedId, time]);
|
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
// defer rendering until we load stylesheets
|
||||||
if (!shouldRender) {
|
if (!shouldRender) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
||||||
|
|
||||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
const finished = time.phase === TimerPhase.Overtime;
|
||||||
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
||||||
const isSelected = runningMessage === TimerMessage.running;
|
|
||||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
||||||
|
|
||||||
const clock = formatTime(time.clock);
|
const clock = formatTime(time.clock);
|
||||||
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
|
const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
|
||||||
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
|
follow?.timeStart,
|
||||||
|
follow?.timeEnd,
|
||||||
|
delay,
|
||||||
|
runtime.offset,
|
||||||
|
);
|
||||||
|
|
||||||
const formatTimer = (): string => {
|
const hideSeconds = searchParams.get('hideTimerSeconds');
|
||||||
if (runningMessage === TimerMessage.ended) {
|
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||||
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' });
|
removeSeconds: isStringBoolean(hideSeconds),
|
||||||
|
removeLeadingZero: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistParam = () => {
|
||||||
|
const eventId = searchParams.get('eventid');
|
||||||
|
if (eventId !== null) {
|
||||||
|
return { id: 'eventid', value: eventId };
|
||||||
}
|
}
|
||||||
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay);
|
const eventIndex = searchParams.get('event');
|
||||||
if (isSelected || runningMessage === TimerMessage.waiting) {
|
if (eventIndex !== null) {
|
||||||
formattedTime = removeLeadingZero(formattedTime);
|
return { id: 'eventindex', value: eventIndex };
|
||||||
}
|
}
|
||||||
return formattedTime;
|
return undefined;
|
||||||
};
|
};
|
||||||
const formattedTimer = formatTimer();
|
|
||||||
|
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const timeOption = getCountdownOptions(defaultFormat);
|
const viewOptions = getCountdownOptions(defaultFormat, persistParam());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||||
<ViewParamsEditor paramFields={timeOption} />
|
<ViewParamsEditor viewOptions={viewOptions} />
|
||||||
{follow === null ? (
|
{follow === null ? (
|
||||||
<CountdownSelect events={backstageEvents} />
|
<CountdownSelect events={backstageEvents} />
|
||||||
) : (
|
) : (
|
||||||
@@ -129,13 +144,25 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
{follow?.title && <div className='title'>{follow.title}</div>}
|
{follow?.title && <div className='title'>{follow.title}</div>}
|
||||||
|
|
||||||
<div className='timer-group'>
|
<div className='timer-group'>
|
||||||
<div className='aux-timers'>
|
{projectedStart && projectedEnd && (
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
|
<div className='timer-group__projected-start'>
|
||||||
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
<div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
|
||||||
|
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projectedStart && projectedEnd && (
|
||||||
|
<div className='timer-group__projected-end'>
|
||||||
|
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
|
||||||
|
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='timer-group__scheduled-start'>
|
||||||
|
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
|
||||||
|
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className='aux-timers'>
|
<div className='timer-group__scheduled-end'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
|
<div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
|
||||||
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
<SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { OntimeEvent, Playback } from 'ontime-types';
|
import { OntimeEvent, Playback } from 'ontime-types';
|
||||||
|
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
import { isStringBoolean } from '../common/viewUtils';
|
||||||
|
|
||||||
export enum TimerMessage {
|
export enum TimerMessage {
|
||||||
toStart = 'to_start',
|
toStart = 'to_start',
|
||||||
@@ -20,48 +22,85 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
|
|||||||
*/
|
*/
|
||||||
export const fetchTimerData = (
|
export const fetchTimerData = (
|
||||||
time: ViewExtendedTimer,
|
time: ViewExtendedTimer,
|
||||||
follow: OntimeEvent,
|
follow: OntimeEvent | null,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
|
offset: number,
|
||||||
): { message: TimerMessage; timer: number } => {
|
): { message: TimerMessage; timer: number } => {
|
||||||
let message;
|
if (follow === null) {
|
||||||
let timer;
|
return { message: TimerMessage.unhandled, timer: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedId === follow.id) {
|
if (selectedId === follow.id) {
|
||||||
// check that is not running
|
// if it is selected, it may not be running
|
||||||
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running;
|
return {
|
||||||
timer = time.current ?? 0;
|
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
|
||||||
} else if (time.clock < follow.timeStart) {
|
timer: time.current ?? 0,
|
||||||
// if it hasnt started, we count to start
|
};
|
||||||
message = TimerMessage.toStart;
|
|
||||||
timer = follow.timeStart - time.clock;
|
|
||||||
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
|
||||||
// if it has started, we show running timer
|
|
||||||
message = TimerMessage.waiting;
|
|
||||||
timer = time.current ?? 0;
|
|
||||||
} else {
|
|
||||||
// running timer timer is not the one we are following
|
|
||||||
|
|
||||||
if (follow.timeStart > follow.timeEnd) {
|
|
||||||
// ends day after
|
|
||||||
|
|
||||||
if (follow.timeStart > time.clock) {
|
|
||||||
// if it hasnt started, we count to start
|
|
||||||
message = TimerMessage.toStart;
|
|
||||||
timer = follow.timeStart - time.clock;
|
|
||||||
} else if (follow.timeStart <= time.clock) {
|
|
||||||
// if it has started, we show running timer
|
|
||||||
message = TimerMessage.waiting;
|
|
||||||
timer = time.current ?? 0;
|
|
||||||
} else {
|
|
||||||
// if it has ended, we show how long ago
|
|
||||||
message = TimerMessage.ended;
|
|
||||||
timer = follow.timeEnd;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if it has ended, we show how long ago
|
|
||||||
message = TimerMessage.ended;
|
|
||||||
timer = follow.timeEnd;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { message, timer };
|
|
||||||
|
const showProjected = getShouldShowProjected();
|
||||||
|
const addedTime = showProjected ? offset : 0;
|
||||||
|
if (time.clock < follow.timeStart) {
|
||||||
|
// if it hasnt started, we count to start
|
||||||
|
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||||
|
// if it has started, we show running timer
|
||||||
|
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// running timer timer is not the one we are following
|
||||||
|
|
||||||
|
// ends day after
|
||||||
|
if (follow.timeStart > follow.timeEnd) {
|
||||||
|
if (follow.timeStart > time.clock) {
|
||||||
|
// if it hasnt started, we count to start
|
||||||
|
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
|
||||||
|
}
|
||||||
|
if (follow.timeStart <= time.clock) {
|
||||||
|
// if it has started, we show running timer
|
||||||
|
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
|
||||||
|
}
|
||||||
|
// if it has ended, we show how long ago
|
||||||
|
return { message: TimerMessage.ended, timer: follow.timeEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it has ended, we show how long ago
|
||||||
|
return { message: TimerMessage.ended, timer: follow.timeEnd };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets values for the timer items
|
||||||
|
*/
|
||||||
|
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
|
||||||
|
if (start == null || end == null) {
|
||||||
|
return {
|
||||||
|
scheduledStart: '',
|
||||||
|
scheduledEnd: '',
|
||||||
|
projectedStart: '',
|
||||||
|
projectedEnd: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const showProjected = getShouldShowProjected();
|
||||||
|
const scheduledStart = formatTime(start + delay);
|
||||||
|
const scheduledEnd = formatTime(end + delay);
|
||||||
|
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
|
||||||
|
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduledStart,
|
||||||
|
scheduledEnd,
|
||||||
|
projectedStart,
|
||||||
|
projectedEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets from the URL whether the showProjected option is active
|
||||||
|
*/
|
||||||
|
function getShouldShowProjected() {
|
||||||
|
const params = new URL(document.location.href).searchParams;
|
||||||
|
return isStringBoolean(params.get('showProjected'));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
const makePersistedField = (id: string, value: string): ViewOption => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: 'Used to keep the selection on submit',
|
||||||
|
description: 'Used to keep the selection on submit',
|
||||||
|
type: 'persist',
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Persisted = { id: string; value: string };
|
||||||
|
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Timer Options' },
|
||||||
|
hideTimerSeconds,
|
||||||
|
{ section: 'View behaviour' },
|
||||||
|
{
|
||||||
|
id: 'showProjected',
|
||||||
|
title: 'Show projected time',
|
||||||
|
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
|
||||||
|
];
|
||||||
@@ -72,6 +72,8 @@
|
|||||||
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
||||||
text-align: var(--lowerThird-text-align-override, left);
|
text-align: var(--lowerThird-text-align-override, left);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@include ellipsis-text();
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '\200b';
|
content: '\200b';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { getLowerThirdOptions } from './lowerThird.options';
|
||||||
|
|
||||||
import './LowerThird.scss';
|
import './LowerThird.scss';
|
||||||
|
|
||||||
type LowerOptions = {
|
type LowerOptions = {
|
||||||
@@ -182,7 +183,7 @@ export default function LowerThird(props: LowerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
||||||
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} />
|
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
|
||||||
<div
|
<div
|
||||||
className={`container container--${playState}`}
|
className={`container container--${playState}`}
|
||||||
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
|
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||||
|
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||||
|
title: 'Title',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||||
|
title: 'Title',
|
||||||
|
none: 'None',
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ section: 'View behaviour' },
|
||||||
|
{
|
||||||
|
id: 'trigger',
|
||||||
|
title: 'Animation Trigger',
|
||||||
|
description: '',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
event: 'Event Load',
|
||||||
|
manual: 'Manual',
|
||||||
|
},
|
||||||
|
defaultValue: 'manual',
|
||||||
|
},
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
{
|
||||||
|
id: 'top-src',
|
||||||
|
title: 'Top Text',
|
||||||
|
description: '',
|
||||||
|
type: 'option',
|
||||||
|
values: topSourceOptions,
|
||||||
|
defaultValue: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-src',
|
||||||
|
title: 'Bottom Text',
|
||||||
|
description: 'Select the data source for the bottom element',
|
||||||
|
type: 'option',
|
||||||
|
values: bottomSourceOptions,
|
||||||
|
defaultValue: 'none',
|
||||||
|
},
|
||||||
|
{ section: 'View style override' },
|
||||||
|
{
|
||||||
|
id: 'top-colour',
|
||||||
|
title: 'Top Text Colour',
|
||||||
|
description: 'Top text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '0000ff (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-colour',
|
||||||
|
title: 'Bottom Text Colour',
|
||||||
|
description: 'Bottom text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '0000ff (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'top-bg',
|
||||||
|
title: 'Top Background Colour',
|
||||||
|
description: 'Top text background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-bg',
|
||||||
|
title: 'Bottom Background Colour',
|
||||||
|
description: 'Bottom text background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'top-size',
|
||||||
|
title: 'Top Text Size',
|
||||||
|
description: 'Font size of the top text',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '65px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-size',
|
||||||
|
title: 'Bottom Text Size',
|
||||||
|
description: 'Font size of the bottom text',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '64px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'width',
|
||||||
|
title: 'Minimum Width',
|
||||||
|
description: 'Minimum Width of the element',
|
||||||
|
type: 'number',
|
||||||
|
prefix: '%',
|
||||||
|
placeholder: '45 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transition',
|
||||||
|
title: 'Transition',
|
||||||
|
description: 'Transition in time in seconds (default 3)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delay',
|
||||||
|
title: 'Delay',
|
||||||
|
description: 'Delay between transition in and out in seconds (default 3)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'key',
|
||||||
|
title: 'Key Colour',
|
||||||
|
description: 'Colour of the background',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'ffffffff (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'line-colour',
|
||||||
|
title: 'Line Colour',
|
||||||
|
description: 'Colour of the line',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'ff0000ff (default)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
|
|||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
|
||||||
|
|
||||||
import './MinimalTimer.scss';
|
import './MinimalTimer.scss';
|
||||||
|
|
||||||
interface MinimalTimerProps {
|
interface MinimalTimerProps {
|
||||||
@@ -161,7 +162,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='minimal-timer'
|
data-testid='minimal-timer'
|
||||||
>
|
>
|
||||||
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
|
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
||||||
|
{ section: 'Timer Options' },
|
||||||
|
hideTimerSeconds,
|
||||||
|
{ section: 'Element visibility' },
|
||||||
|
{
|
||||||
|
id: 'hideovertime',
|
||||||
|
title: 'Hide Overtime',
|
||||||
|
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideendmessage',
|
||||||
|
title: 'Hide End Message',
|
||||||
|
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{ section: 'View style override' },
|
||||||
|
{
|
||||||
|
id: 'key',
|
||||||
|
title: 'Key Colour',
|
||||||
|
description: 'Background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text',
|
||||||
|
title: 'Text Colour',
|
||||||
|
description: 'Text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'fffff (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'textbg',
|
||||||
|
title: 'Text Background',
|
||||||
|
description: 'Colour of text background in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'font',
|
||||||
|
title: 'Font',
|
||||||
|
description: 'Font family, will use the fonts available in the system',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'Arial Black (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size',
|
||||||
|
title: 'Text Size',
|
||||||
|
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '1 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alignx',
|
||||||
|
title: 'Align Horizontal',
|
||||||
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
|
type: 'option',
|
||||||
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'offsetx',
|
||||||
|
title: 'Offset Horizontal',
|
||||||
|
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aligny',
|
||||||
|
title: 'Align Vertical',
|
||||||
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
|
type: 'option',
|
||||||
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'offsety',
|
||||||
|
title: 'Offset Vertical',
|
||||||
|
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: clamp(32px, 3.5vw, 50px);
|
||||||
font-weight: 600;
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
line-height: 0.95em;
|
line-height: 0.95em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -19,8 +18,12 @@ import { titleVariants } from '../common/animation';
|
|||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { getPublicOptions } from './public.options';
|
||||||
|
|
||||||
import './Public.scss';
|
import './Public.scss';
|
||||||
|
|
||||||
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
@@ -71,7 +74,7 @@ export default function Public(props: BackstageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<ViewParamsEditor paramFields={publicOptions} />
|
<ViewParamsEditor viewOptions={publicOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
@@ -83,31 +86,33 @@ export default function Public(props: BackstageProps) {
|
|||||||
<div className='now-container'>
|
<div className='now-container'>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{publicEventNow && (
|
{publicEventNow && (
|
||||||
<motion.div
|
<MotionTitleCard
|
||||||
className='event now'
|
className='event now'
|
||||||
key='now'
|
key='now'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
label='now'
|
||||||
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} />
|
title={publicEventNow.title}
|
||||||
</motion.div>
|
secondary={secondaryTextNow}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{publicEventNext && (
|
{publicEventNext && (
|
||||||
<motion.div
|
<MotionTitleCard
|
||||||
className='event next'
|
className='event next'
|
||||||
key='next'
|
key='next'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
label='next'
|
||||||
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} />
|
title={publicEventNext.title}
|
||||||
</motion.div>
|
secondary={secondaryTextNext}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
|
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
{
|
||||||
|
id: 'secondary-src',
|
||||||
|
title: 'Event secondary text',
|
||||||
|
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||||
|
type: 'option',
|
||||||
|
values: secondaryOptions,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{ section: 'Schedule options' },
|
||||||
|
{
|
||||||
|
id: 'eventsPerPage',
|
||||||
|
title: 'Events per page',
|
||||||
|
description: 'Sets the number of events on the page, can cause overflow',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '8 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide past events',
|
||||||
|
description: 'Scheduler will only show upcoming events',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stopCycle',
|
||||||
|
title: 'Stop cycling through event pages',
|
||||||
|
description: 'Schedule will not auto-cycle through events',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -168,7 +168,6 @@ $orange-active: #f60;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&--now {
|
&--now {
|
||||||
color: var(--studio-active-label, $red-active);
|
color: var(--studio-active-label, $red-active);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { isOntimeEvent, Playback } from 'ontime-types';
|
|||||||
import { millisToString, removeSeconds } from 'ontime-utils';
|
import { millisToString, removeSeconds } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import useFitText from '../../../common/hooks/useFitText';
|
import useFitText from '../../../common/hooks/useFitText';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
@@ -14,6 +13,7 @@ import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
|||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { isStringBoolean } from '../common/viewUtils';
|
import { isStringBoolean } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { getStudioClockOptions } from './studioClock.options';
|
||||||
import { secondsInMillis, trimRundown } from './studioClock.utils';
|
import { secondsInMillis, trimRundown } from './studioClock.utils';
|
||||||
|
|
||||||
import './StudioClock.scss';
|
import './StudioClock.scss';
|
||||||
@@ -36,7 +36,7 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
|
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
|
||||||
// deferring rendering seems to affect styling (font and useFitText)
|
// deferring rendering seems to affect styling (font and useFitText)
|
||||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
|
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
|
||||||
|
|
||||||
const activeIndicators = [...Array(12).keys()];
|
const activeIndicators = [...Array(12).keys()];
|
||||||
const secondsIndicators = [...Array(60).keys()];
|
const secondsIndicators = [...Array(60).keys()];
|
||||||
@@ -75,7 +75,7 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
||||||
<ViewParamsEditor paramFields={studioClockOptions} />
|
<ViewParamsEditor viewOptions={studioClockOptions} />
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
||||||
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
|
||||||
|
import type { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Timer Options' },
|
||||||
|
hideTimerSeconds,
|
||||||
|
];
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto;
|
grid-template-columns: 100%;
|
||||||
grid-template-rows: auto 1fr auto auto auto;
|
grid-template-rows: auto 1fr auto auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'clock'
|
'clock'
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import { overrideStylesURL } from '../../../common/api/constants';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
@@ -25,6 +24,8 @@ import { useTranslation } from '../../../translation/TranslationProvider';
|
|||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||||
|
|
||||||
|
import { getTimerOptions } from './timer.options';
|
||||||
|
|
||||||
import './Timer.scss';
|
import './Timer.scss';
|
||||||
|
|
||||||
// motion
|
// motion
|
||||||
@@ -43,6 +44,8 @@ const titleVariants = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface TimerProps {
|
interface TimerProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
@@ -156,7 +159,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
<ViewParamsEditor paramFields={timerOptions} />
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
|
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
@@ -211,31 +214,33 @@ export default function Timer(props: TimerProps) {
|
|||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{eventNow?.title && (
|
{eventNow?.title && (
|
||||||
<motion.div
|
<MotionTitleCard
|
||||||
className='event now'
|
className='event now'
|
||||||
key='now'
|
key='now'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
label='now'
|
||||||
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
|
title={mainFieldNow}
|
||||||
</motion.div>
|
secondary={secondaryTextNow}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{eventNext?.title && (
|
{eventNext?.title && (
|
||||||
<motion.div
|
<MotionTitleCard
|
||||||
className='event next'
|
|
||||||
key='next'
|
key='next'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
animate='visible'
|
animate='visible'
|
||||||
exit='exit'
|
exit='exit'
|
||||||
>
|
label='next'
|
||||||
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
|
title={mainFieldNext}
|
||||||
</motion.div>
|
secondary={secondaryTextNext}
|
||||||
|
className='event next'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
hideTimerSeconds,
|
||||||
|
makeOptionsFromCustomFields,
|
||||||
|
showLeadingZeros,
|
||||||
|
} from '../../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
|
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||||
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||||
|
return [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Timer Options' },
|
||||||
|
hideTimerSeconds,
|
||||||
|
showLeadingZeros,
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
title: 'Main text',
|
||||||
|
description: 'Select the data source for the main text',
|
||||||
|
type: 'option',
|
||||||
|
values: mainOptions,
|
||||||
|
defaultValue: 'Title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary-src',
|
||||||
|
title: 'Secondary text',
|
||||||
|
description: 'Select the data source for the secondary text',
|
||||||
|
type: 'option',
|
||||||
|
values: secondaryOptions,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{ section: 'Element visibility' },
|
||||||
|
{
|
||||||
|
id: 'hideClock',
|
||||||
|
title: 'Hide Time Now',
|
||||||
|
description: 'Hides the Time Now field',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideCards',
|
||||||
|
title: 'Hide Cards',
|
||||||
|
description: 'Hides the Now and Next cards',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideProgress',
|
||||||
|
title: 'Hide progress bar',
|
||||||
|
description: 'Hides the progress bar',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideMessage',
|
||||||
|
title: 'Hide Presenter Message',
|
||||||
|
description: 'Prevents the screen from displaying messages from the presenter',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideExternal',
|
||||||
|
title: 'Hide External',
|
||||||
|
description: 'Prevents the screen from displaying the external field',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@use "ontimeStyles" as *;
|
@use 'ontimeStyles' as *;
|
||||||
|
|
||||||
//////////////////////////////////// general app elements
|
//////////////////////////////////// general app elements
|
||||||
|
|
||||||
@@ -15,3 +15,9 @@
|
|||||||
color: $ontime-color;
|
color: $ontime-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin ellipsis-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ $bg-container-onlight: $gray-100;
|
|||||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
|
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
|
||||||
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
|
||||||
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
||||||
|
|
||||||
$modal-note-color: $gray-700;
|
$modal-note-color: $gray-700;
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ $timer-finished-color: $playback-negative;
|
|||||||
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
|
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
|
||||||
|
|
||||||
$external-color: rgba(white, 70%); // --external-color-override
|
$external-color: rgba(white, 70%); // --external-color-override
|
||||||
|
|
||||||
|
// properties of other timers (clock and countdown)
|
||||||
|
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
||||||
|
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||||
@@ -8,6 +8,7 @@ import { langEs } from './languages/es';
|
|||||||
import { langFr } from './languages/fr';
|
import { langFr } from './languages/fr';
|
||||||
import { langIt } from './languages/it';
|
import { langIt } from './languages/it';
|
||||||
import { langNo } from './languages/no';
|
import { langNo } from './languages/no';
|
||||||
|
import { langPl } from './languages/pl';
|
||||||
import { langPt } from './languages/pt';
|
import { langPt } from './languages/pt';
|
||||||
import { langSv } from './languages/sv';
|
import { langSv } from './languages/sv';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const translationsList = {
|
|||||||
no: langNo,
|
no: langNo,
|
||||||
pt: langPt,
|
pt: langPt,
|
||||||
sv: langSv,
|
sv: langSv,
|
||||||
|
pl: langPl,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langDe: TranslationObject = {
|
export const langDe: TranslationObject = {
|
||||||
'common.end_time': 'Endzeit',
|
'common.expected_finish': 'Erwartetes Ende',
|
||||||
'common.expected_finish': 'Voraussichtliches Ende',
|
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Jetzt',
|
'common.now': 'Jetzt',
|
||||||
'common.next': 'Nächste',
|
'common.next': 'Nächste',
|
||||||
'common.public_message': 'Öffentliche Nachricht',
|
'common.public_message': 'Öffentliche Nachricht',
|
||||||
'common.start_time': 'Startzeit',
|
'common.scheduled_start': 'Geplanter beginn',
|
||||||
|
'common.scheduled_end': 'Geplantes ende',
|
||||||
|
'common.projected_start': 'Erwartetes beginn',
|
||||||
|
'common.projected_end': 'Erwartetes ende',
|
||||||
'common.stage_timer': 'Bühnen-Timer',
|
'common.stage_timer': 'Bühnen-Timer',
|
||||||
'common.started_at': 'Gestartet am',
|
'common.started_at': 'Gestartet am',
|
||||||
'common.time_now': 'Aktuelle Zeit',
|
'common.time_now': 'Aktuelle Zeit',
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export const langEn = {
|
export const langEn = {
|
||||||
'common.end_time': 'End Time',
|
|
||||||
'common.expected_finish': 'Expected Finish',
|
'common.expected_finish': 'Expected Finish',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Now',
|
'common.now': 'Now',
|
||||||
'common.next': 'Next',
|
'common.next': 'Next',
|
||||||
'common.public_message': 'Public message',
|
'common.public_message': 'Public message',
|
||||||
'common.start_time': 'Start Time',
|
'common.scheduled_start': 'Scheduled start',
|
||||||
|
'common.scheduled_end': 'Scheduled end',
|
||||||
|
'common.projected_start': 'Projected start',
|
||||||
|
'common.projected_end': 'Projected end',
|
||||||
'common.stage_timer': 'Stage Timer',
|
'common.stage_timer': 'Stage Timer',
|
||||||
'common.started_at': 'Started At',
|
'common.started_at': 'Started At',
|
||||||
'common.time_now': 'Time now',
|
'common.time_now': 'Time now',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langEs: TranslationObject = {
|
export const langEs: TranslationObject = {
|
||||||
'common.end_time': 'Hora de finalización',
|
|
||||||
'common.expected_finish': 'Finalización esperada',
|
'common.expected_finish': 'Finalización esperada',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Ahora',
|
'common.now': 'Ahora',
|
||||||
'common.next': 'Siguiente',
|
'common.next': 'Siguiente',
|
||||||
'common.public_message': 'Mensaje público',
|
'common.public_message': 'Mensaje público',
|
||||||
'common.start_time': 'Hora de inicio',
|
'common.scheduled_start': 'Inicio programado',
|
||||||
|
'common.scheduled_end': 'Fin programado',
|
||||||
|
'common.projected_start': 'Inicio previsto',
|
||||||
|
'common.projected_end': 'Fin previsto',
|
||||||
'common.stage_timer': 'Temporizador de presentador',
|
'common.stage_timer': 'Temporizador de presentador',
|
||||||
'common.started_at': 'Iniciado en',
|
'common.started_at': 'Iniciado en',
|
||||||
'common.time_now': 'Ahora',
|
'common.time_now': 'Ahora',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langFr: TranslationObject = {
|
export const langFr: TranslationObject = {
|
||||||
'common.end_time': 'Termine à',
|
|
||||||
'common.expected_finish': 'Fin estimée à',
|
'common.expected_finish': 'Fin estimée à',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Maintenant',
|
'common.now': 'Maintenant',
|
||||||
'common.next': 'A suivre',
|
'common.next': 'A suivre',
|
||||||
'common.public_message': 'Message public',
|
'common.public_message': 'Message public',
|
||||||
'common.start_time': 'Heure de début',
|
'common.scheduled_start': 'Début prévu',
|
||||||
|
'common.scheduled_end': 'Fin prévue',
|
||||||
|
'common.projected_start': 'Début projeté',
|
||||||
|
'common.projected_end': 'Fin projetée',
|
||||||
'common.stage_timer': 'Minuteur de scène',
|
'common.stage_timer': 'Minuteur de scène',
|
||||||
'common.started_at': 'Commencé à',
|
'common.started_at': 'Commencé à',
|
||||||
'common.time_now': 'Heure',
|
'common.time_now': 'Heure',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langIt: TranslationObject = {
|
export const langIt: TranslationObject = {
|
||||||
'common.end_time': 'Ora di Fine',
|
|
||||||
'common.expected_finish': 'Fine Prevista',
|
'common.expected_finish': 'Fine Prevista',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Adesso',
|
'common.now': 'Adesso',
|
||||||
'common.next': 'Prossimo',
|
'common.next': 'Prossimo',
|
||||||
'common.public_message': 'Messaggio pubblico',
|
'common.public_message': 'Messaggio pubblico',
|
||||||
'common.start_time': 'Ora di Inizio',
|
'common.scheduled_start': 'Inizio programmato',
|
||||||
|
'common.scheduled_end': 'Fine programmata',
|
||||||
|
'common.projected_start': 'Inizio previsto',
|
||||||
|
'common.projected_end': 'Fine prevista',
|
||||||
'common.stage_timer': 'Orologio Palco',
|
'common.stage_timer': 'Orologio Palco',
|
||||||
'common.started_at': 'Iniziato Alle',
|
'common.started_at': 'Iniziato Alle',
|
||||||
'common.time_now': 'Ora attuale',
|
'common.time_now': 'Ora attuale',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langNo: TranslationObject = {
|
export const langNo: TranslationObject = {
|
||||||
'common.end_time': 'Sluttid',
|
|
||||||
'common.expected_finish': 'Forventet slutt',
|
'common.expected_finish': 'Forventet slutt',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Nå',
|
'common.now': 'Nå',
|
||||||
'common.next': 'Neste',
|
'common.next': 'Neste',
|
||||||
'common.public_message': 'Offentlig beskjed',
|
'common.public_message': 'Offentlig beskjed',
|
||||||
'common.start_time': 'Starttid',
|
'common.scheduled_start': 'Planlagt start',
|
||||||
|
'common.scheduled_end': 'Planlagt slutt',
|
||||||
|
'common.projected_start': 'Forventet start',
|
||||||
|
'common.projected_end': 'Forventet slutt',
|
||||||
'common.stage_timer': 'Scenetimer',
|
'common.stage_timer': 'Scenetimer',
|
||||||
'common.started_at': 'Startet',
|
'common.started_at': 'Startet',
|
||||||
'common.time_now': 'Klokken nå',
|
'common.time_now': 'Klokken nå',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
|
export const langPl: TranslationObject = {
|
||||||
|
'common.expected_finish': 'Zakładany czas zakończenia',
|
||||||
|
'common.minutes': 'min',
|
||||||
|
'common.now': 'Teraz',
|
||||||
|
'common.next': 'Następnie',
|
||||||
|
'common.public_message': 'Wiadomość publiczna',
|
||||||
|
'common.scheduled_start': 'Planowany początek',
|
||||||
|
'common.scheduled_end': 'Planowany koniec',
|
||||||
|
'common.projected_start': 'Przewidywany początek',
|
||||||
|
'common.projected_end': 'Przewidywany koniec',
|
||||||
|
'common.stage_timer': 'Timer Scena',
|
||||||
|
'common.started_at': 'Rozpoczęte o',
|
||||||
|
'common.time_now': 'Aktualny czas',
|
||||||
|
'countdown.ended': 'Zakończone o',
|
||||||
|
'countdown.running': 'Trwa',
|
||||||
|
'countdown.select_event': 'Wybierz event który chcesz śledzić',
|
||||||
|
'countdown.to_start': 'Do rozpoczęcia',
|
||||||
|
'countdown.waiting': 'Oczekiwanie na start',
|
||||||
|
'countdown.overtime': 'ponad czasem',
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langPt: TranslationObject = {
|
export const langPt: TranslationObject = {
|
||||||
'common.end_time': 'Hora de término',
|
|
||||||
'common.expected_finish': 'Término esperado',
|
'common.expected_finish': 'Término esperado',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Agora',
|
'common.now': 'Agora',
|
||||||
'common.next': 'Próximo',
|
'common.next': 'Próximo',
|
||||||
'common.public_message': 'Mensagem pública',
|
'common.public_message': 'Mensagem pública',
|
||||||
'common.start_time': 'Hora de início',
|
'common.scheduled_start': 'Início programado',
|
||||||
|
'common.scheduled_end': 'Fim programado',
|
||||||
|
'common.projected_start': 'Início previsto',
|
||||||
|
'common.projected_end': 'Fim previsto',
|
||||||
'common.stage_timer': 'Temporizador do presentador',
|
'common.stage_timer': 'Temporizador do presentador',
|
||||||
'common.started_at': 'Iniciado em',
|
'common.started_at': 'Iniciado em',
|
||||||
'common.time_now': 'Hora atual',
|
'common.time_now': 'Hora atual',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TranslationObject } from './en';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langSv: TranslationObject = {
|
export const langSv: TranslationObject = {
|
||||||
'common.end_time': 'Sluttid',
|
|
||||||
'common.expected_finish': 'Förväntat slut',
|
'common.expected_finish': 'Förväntat slut',
|
||||||
'common.minutes': 'min',
|
'common.minutes': 'min',
|
||||||
'common.now': 'Nu',
|
'common.now': 'Nu',
|
||||||
'common.next': 'Nästa',
|
'common.next': 'Nästa',
|
||||||
'common.public_message': 'Offentligt meddelande',
|
'common.public_message': 'Offentligt meddelande',
|
||||||
'common.start_time': 'Starttid',
|
'common.scheduled_start': 'Planerad start',
|
||||||
|
'common.scheduled_end': 'Planerad slut',
|
||||||
|
'common.projected_start': 'Beräknad start',
|
||||||
|
'common.projected_end': 'Beräknad slut',
|
||||||
'common.stage_timer': 'Timer för scenen',
|
'common.stage_timer': 'Timer för scenen',
|
||||||
'common.started_at': 'Började vid',
|
'common.started_at': 'Började vid',
|
||||||
'common.time_now': 'Klockan nu',
|
'common.time_now': 'Klockan nu',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user