mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8bc4eb098 | |||
| 807698aee1 | |||
| 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 |
@@ -14,12 +14,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -89,12 +89,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
name: Ontime CLI build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_cli:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -18,12 +20,13 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -43,8 +46,8 @@ jobs:
|
||||
run: cp -R apps/server/src/external apps/cli/external
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --access public
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
working-directory: ./apps/cli
|
||||
working-directory: ./apps/cli
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Docker Image CI Ontime
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,8 +14,6 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2.1.0
|
||||
@@ -32,10 +30,10 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
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: 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
|
||||
if: github.event.release.prerelease == true
|
||||
@@ -43,8 +41,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
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: 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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
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.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (~18.18.2)
|
||||
- __pnpm__ (>=8)
|
||||
- __node__ (~20)
|
||||
- __pnpm__ (~9)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## 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 PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN npm install -g pnpm@9.5.0
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||
|
||||
FROM node:18.18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
ENV NODE_ENV=docker
|
||||
# Ontime Data path
|
||||
ENV ONTIME_DATA=/external/
|
||||
ENV ONTIME_DATA=/data/
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ npx install @getontime/cli
|
||||
|
||||
## Sponsoring
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See the [terms of fonations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||
|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.1.0",
|
||||
"version": "3.4.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -70,8 +70,8 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
@@ -86,7 +86,7 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
filename: newFilename,
|
||||
newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
.title-card__title,
|
||||
.title-card__secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||
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 {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
@@ -28,13 +40,3 @@
|
||||
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 './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, secondary } = props;
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`title-card ${className}`} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
|
||||
@@ -27,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
const { paramField } = props;
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'option') {
|
||||
const optionFromParams = searchParams.get(id);
|
||||
const defaultOptionValue = optionFromParams || defaultValue;
|
||||
|
||||
@@ -13,14 +13,24 @@
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
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;
|
||||
padding: $section-spacing 0;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { ParamField } from './types';
|
||||
import { isSection, ViewOption } from './types';
|
||||
|
||||
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
|
||||
*/
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
||||
acc[id] = String(defaultValue);
|
||||
return acc;
|
||||
}, {});
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
|
||||
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) {
|
||||
newSearchParams.set(id, value);
|
||||
}
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
});
|
||||
return newSearchParams;
|
||||
};
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
paramFields: ParamField[];
|
||||
viewOptions: ViewOption[];
|
||||
}
|
||||
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
@@ -68,7 +79,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
formEvent.preventDefault();
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
onClose();
|
||||
@@ -85,15 +96,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
<DrawerBody>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
<label className={style.label}>
|
||||
<span className={style.title}>{field.title}</span>
|
||||
<span className={style.description}>{field.description}</span>
|
||||
<ParamInput key={field.title} paramField={field} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{viewOptions.map((option) => {
|
||||
if (isSection(option)) {
|
||||
return (
|
||||
<div key={option.section} className={style.section}>
|
||||
{option.section}
|
||||
</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>
|
||||
</DrawerBody>
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { type ParamField } from './types';
|
||||
import type { ParamField } from './types';
|
||||
|
||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
||||
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
|
||||
}, additionalOptions ?? {});
|
||||
return customFieldOptions;
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Record<string, string> = {},
|
||||
) => {
|
||||
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)`;
|
||||
return {
|
||||
id: 'timeformat',
|
||||
@@ -20,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
|
||||
};
|
||||
};
|
||||
|
||||
const hideTimerSeconds: ParamField = {
|
||||
export const hideTimerSeconds: ParamField = {
|
||||
id: 'hideTimerSeconds',
|
||||
title: 'Hide seconds in timer',
|
||||
description: 'Whether to hide seconds in the running timer',
|
||||
@@ -28,484 +31,10 @@ const hideTimerSeconds: ParamField = {
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const showLeadingZeros: ParamField = {
|
||||
export const showLeadingZeros: ParamField = {
|
||||
id: 'showLeadingZeros',
|
||||
title: 'Show leading zeros in timer',
|
||||
description: 'Whether to show leading zeros in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
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 = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -19,5 +23,15 @@ type MultiOptionsField = {
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
|
||||
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||
const isLongPressActive = useRef(false);
|
||||
const isPressed = useRef(false);
|
||||
const timerId = useRef<NodeJS.Timer>();
|
||||
const timerId = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useMemo(() => {
|
||||
const start = (event: SyntheticEvent) => {
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function GeneralPanelForm() {
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function LogExport() {
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Network log
|
||||
Event log
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectFormValues = {
|
||||
@@ -15,10 +13,9 @@ interface ProjectFormProps {
|
||||
filename: string;
|
||||
onCancel: () => 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 {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
id='filename'
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
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'
|
||||
type='text'
|
||||
variant='ontime-filled'
|
||||
placeholder='Enter new name'
|
||||
autoComplete='off'
|
||||
{...register('filename')}
|
||||
/>
|
||||
<div className={style.actionButtons}>
|
||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<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>}
|
||||
</>
|
||||
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||
type='submit'
|
||||
className={style.saveButton}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renameProject,
|
||||
} from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
|
||||
@@ -40,36 +41,53 @@ export default function ProjectListItem({
|
||||
onToggleEditMode,
|
||||
}: ProjectListItemProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmitRename = async (values: ProjectFormValues) => {
|
||||
try {
|
||||
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
|
||||
return async (values: ProjectFormValues) => {
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
try {
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
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();
|
||||
onSubmit();
|
||||
await invalidateAllCaches();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
|
||||
const handleDelete = async (filename: string) => {
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
}
|
||||
await duplicateProject(filename, values.filename);
|
||||
await deleteProject(filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,50 +104,55 @@ export default function ProjectListItem({
|
||||
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
||||
|
||||
return (
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
action={editingMode}
|
||||
filename={filename}
|
||||
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
|
||||
onCancel={handleCancel}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
<>
|
||||
{submitError && (
|
||||
<tr key='filename-error'>
|
||||
<td colSpan={99}>
|
||||
<Panel.Error>{submitError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
action={editingMode}
|
||||
filename={filename}
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onRefetch={onRefetch}
|
||||
onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</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({
|
||||
current,
|
||||
filename,
|
||||
onChangeEditMode,
|
||||
onRefetch,
|
||||
}: {
|
||||
interface ActionMenuProps {
|
||||
current?: boolean;
|
||||
filename: string;
|
||||
isDisabled: boolean;
|
||||
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
||||
onRefetch: () => Promise<void>;
|
||||
}) {
|
||||
const handleLoad = async () => {
|
||||
await loadProject(filename);
|
||||
await invalidateAllCaches();
|
||||
};
|
||||
onDelete: (filename: string) => void;
|
||||
onLoad: (filename: string) => void;
|
||||
}
|
||||
function ActionMenu(props: ActionMenuProps) {
|
||||
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
|
||||
|
||||
const handleRename = () => {
|
||||
onChangeEditMode('rename', filename);
|
||||
@@ -139,11 +162,6 @@ function ActionMenu({
|
||||
onChangeEditMode('duplicate', filename);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteProject(filename);
|
||||
await onRefetch();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await downloadProject(filename);
|
||||
};
|
||||
@@ -161,16 +179,17 @@ function ActionMenu({
|
||||
color='#e2e2e2' // $gray-200
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleLoad} isDisabled={current}>
|
||||
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
|
||||
Load
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
||||
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
|
||||
<MenuItem isDisabled={current} onClick={handleDelete}>
|
||||
<MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
|
||||
@@ -57,7 +57,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Application log',
|
||||
label: 'Event log',
|
||||
},
|
||||
{
|
||||
id: 'network__clients',
|
||||
|
||||
@@ -47,11 +47,13 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: $gray-1300;
|
||||
|
||||
font-size: $table-header-font-size;
|
||||
color: $gray-700;
|
||||
font-weight: 400;
|
||||
|
||||
th {
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
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 CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { initialColumnOrder } from './cuesheetCols';
|
||||
import useColumnManager from './useColumnManager';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
@@ -27,23 +26,20 @@ interface CuesheetProps {
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
key: 'table-order',
|
||||
defaultValue: initialColumnOrder,
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||
const {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
columnSizing,
|
||||
resetColumnOrder,
|
||||
setColumnVisibility,
|
||||
saveColumnOrder,
|
||||
setColumnSizing,
|
||||
} = useColumnManager(columns);
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
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({
|
||||
data,
|
||||
columns,
|
||||
@@ -61,10 +57,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const resetColumnOrder = () => {
|
||||
saveColumnOrder(initialColumnOrder);
|
||||
};
|
||||
|
||||
const setAllVisible = () => {
|
||||
table.toggleAllColumnsVisible(true);
|
||||
};
|
||||
|
||||
@@ -117,5 +117,3 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
...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 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 useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
@@ -22,6 +21,7 @@ import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import { getOperatorOptions } from './operator.options';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function Operator() {
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
<ViewParamsEditor viewOptions={operatorOptions} />
|
||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||
|
||||
<StatusBar
|
||||
|
||||
@@ -65,12 +65,15 @@
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: $ui-white;
|
||||
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.secondaryField {
|
||||
grid-area: secondary;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
@@ -96,10 +99,10 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.noColour {
|
||||
@@ -110,6 +113,8 @@
|
||||
}
|
||||
|
||||
.value {
|
||||
padding-inline: 0.25rem;
|
||||
background-color: $gray-1250;
|
||||
color: var(--operator-highlight-override, $ui-white);
|
||||
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';
|
||||
|
||||
interface CountedTextInputProps extends InputProps {
|
||||
interface EventTextInputProps extends InputProps {
|
||||
field: EditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
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 ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textfield'
|
||||
value={value}
|
||||
maxLength={maxLength || 50}
|
||||
maxLength={maxLength || 100}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Message,
|
||||
OntimeEvent,
|
||||
ProjectData,
|
||||
Runtime,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimerMessage,
|
||||
@@ -36,6 +37,7 @@ type WithDataProps = {
|
||||
publicEventNext: OntimeEvent | null;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
publicSelectedId: string | null;
|
||||
runtime: Runtime;
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
}, [rundownData]);
|
||||
|
||||
// websocket data
|
||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } =
|
||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
|
||||
useStore(runtimeStore);
|
||||
const publicSelectedId = publicEventNow?.id ?? null;
|
||||
const selectedId = eventNow?.id ?? null;
|
||||
@@ -108,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
publicEventNext={publicEventNext}
|
||||
publicEventNow={publicEventNow}
|
||||
publicSelectedId={publicSelectedId}
|
||||
runtime={runtime}
|
||||
selectedId={selectedId}
|
||||
settings={settings}
|
||||
time={TimeManagerType}
|
||||
|
||||
@@ -11,7 +11,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
@@ -23,8 +22,12 @@ import { titleVariants } from '../common/animation';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import { getBackstageOptions } from './backstage.options';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
@@ -97,7 +100,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<ViewParamsEditor paramFields={backstageOptions} />
|
||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
@@ -124,7 +127,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
@@ -151,16 +154,17 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
secondary={secondaryTextNext}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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 { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
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 SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import { getClockOptions } from './clock.options';
|
||||
|
||||
import './Clock.scss';
|
||||
|
||||
interface ClockProps {
|
||||
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
|
||||
}}
|
||||
data-testid='clock-view'
|
||||
>
|
||||
<ViewParamsEditor paramFields={clockOptions} />
|
||||
<ViewParamsEditor viewOptions={clockOptions} />
|
||||
<SuperscriptTime
|
||||
time={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 { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
|
||||
@@ -68,11 +69,13 @@ export function getFormattedTimer(
|
||||
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
|
||||
}
|
||||
|
||||
let timeToParse = timer;
|
||||
if (timerType === TimerType.Clock) {
|
||||
return formatTime(timer);
|
||||
}
|
||||
|
||||
let timeToParse = timer;
|
||||
if (options.removeSeconds) {
|
||||
const timerIsTimeOfDay = timerType === TimerType.Clock;
|
||||
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
|
||||
const isNegative = timeToParse < -MILLIS_PER_SECOND && timerType !== TimerType.CountUp;
|
||||
if (isNegative) {
|
||||
// in negative numbers, we need to round down
|
||||
timeToParse -= MILLIS_PER_MINUTE;
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
flex-direction: column;
|
||||
|
||||
&__title {
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
font-size: clamp(1.5rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
&__events {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
margin-top: 1em;
|
||||
overflow-y: auto;
|
||||
height: 70vh;
|
||||
@@ -38,18 +38,18 @@
|
||||
.countdown-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
gap: min(2vh, 1rem);
|
||||
padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'status'
|
||||
'clock'
|
||||
'title'
|
||||
'timers';
|
||||
'header'
|
||||
'status'
|
||||
'clock'
|
||||
'title'
|
||||
'timers';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
@@ -59,13 +59,13 @@
|
||||
font-weight: 600;
|
||||
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-size: $timer-value-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
@@ -75,7 +75,7 @@
|
||||
.status {
|
||||
grid-area: status;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
.title {
|
||||
grid-area: title;
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 16px 24px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
|
||||
font-weight: 600;
|
||||
@@ -119,28 +119,49 @@
|
||||
|
||||
.timer-group {
|
||||
grid-area: timers;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'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 {
|
||||
text-align: center;
|
||||
font-size: clamp(24px, 1.75vw, 32px);
|
||||
&__projected-start {
|
||||
grid-area: projected-start;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
&__projected-end {
|
||||
grid-area: projected-end;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
&__scheduled-start {
|
||||
grid-area: scheduled-start;
|
||||
}
|
||||
|
||||
&--delayed {
|
||||
color: $delay-color;
|
||||
}
|
||||
&__scheduled-end {
|
||||
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 { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
import {
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
Playback,
|
||||
Runtime,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimerPhase,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
|
||||
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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
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 { useTranslation } from '../../../translation/TranslationProvider';
|
||||
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 './Countdown.scss';
|
||||
@@ -21,27 +30,26 @@ import './Countdown.scss';
|
||||
interface CountdownProps {
|
||||
isMirrored: boolean;
|
||||
backstageEvents: OntimeEvent[];
|
||||
time: ViewExtendedTimer;
|
||||
runtime: Runtime;
|
||||
selectedId: string | null;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
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 [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||
const [runningTimer, setRunningTimer] = useState(0);
|
||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useWindowTitle('Countdown');
|
||||
|
||||
// eg. http://localhost:4001/countdown?eventId=ei0us
|
||||
// Check for user options
|
||||
// update data to the event we are following
|
||||
useEffect(() => {
|
||||
if (!backstageEvents) {
|
||||
return;
|
||||
@@ -50,6 +58,12 @@ export default function Countdown(props: CountdownProps) {
|
||||
const eventId = searchParams.get('eventid');
|
||||
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;
|
||||
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
|
||||
|
||||
@@ -66,49 +80,50 @@ export default function Countdown(props: CountdownProps) {
|
||||
}
|
||||
}, [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
|
||||
if (!shouldRender) {
|
||||
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 finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
||||
const isSelected = runningMessage === TimerMessage.running;
|
||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
|
||||
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
|
||||
const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
|
||||
follow?.timeStart,
|
||||
follow?.timeEnd,
|
||||
delay,
|
||||
runtime.offset,
|
||||
);
|
||||
|
||||
const formatTimer = (): string => {
|
||||
if (runningMessage === TimerMessage.ended) {
|
||||
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
const hideSeconds = searchParams.get('hideTimerSeconds');
|
||||
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||
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);
|
||||
if (isSelected || runningMessage === TimerMessage.waiting) {
|
||||
formattedTime = removeLeadingZero(formattedTime);
|
||||
const eventIndex = searchParams.get('event');
|
||||
if (eventIndex !== null) {
|
||||
return { id: 'eventindex', value: eventIndex };
|
||||
}
|
||||
return formattedTime;
|
||||
return undefined;
|
||||
};
|
||||
const formattedTimer = formatTimer();
|
||||
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const timeOption = getCountdownOptions(defaultFormat);
|
||||
const viewOptions = getCountdownOptions(defaultFormat, persistParam());
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
<ViewParamsEditor paramFields={timeOption} />
|
||||
<ViewParamsEditor viewOptions={viewOptions} />
|
||||
{follow === null ? (
|
||||
<CountdownSelect events={backstageEvents} />
|
||||
) : (
|
||||
@@ -129,13 +144,25 @@ export default function Countdown(props: CountdownProps) {
|
||||
{follow?.title && <div className='title'>{follow.title}</div>}
|
||||
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
|
||||
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
||||
{projectedStart && projectedEnd && (
|
||||
<div className='timer-group__projected-start'>
|
||||
<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 className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
|
||||
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
||||
<div className='timer-group__scheduled-end'>
|
||||
<div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
|
||||
<SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
export enum TimerMessage {
|
||||
toStart = 'to_start',
|
||||
@@ -20,48 +22,85 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
|
||||
*/
|
||||
export const fetchTimerData = (
|
||||
time: ViewExtendedTimer,
|
||||
follow: OntimeEvent,
|
||||
follow: OntimeEvent | null,
|
||||
selectedId: string | null,
|
||||
offset: number,
|
||||
): { message: TimerMessage; timer: number } => {
|
||||
let message;
|
||||
let timer;
|
||||
if (follow === null) {
|
||||
return { message: TimerMessage.unhandled, timer: 0 };
|
||||
}
|
||||
|
||||
if (selectedId === follow.id) {
|
||||
// check that is not running
|
||||
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running;
|
||||
timer = time.current ?? 0;
|
||||
} else if (time.clock < follow.timeStart) {
|
||||
// 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;
|
||||
}
|
||||
// if it is selected, it may not be running
|
||||
return {
|
||||
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
|
||||
timer: time.current ?? 0,
|
||||
};
|
||||
}
|
||||
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;
|
||||
text-align: var(--lowerThird-text-align-override, left);
|
||||
white-space: nowrap;
|
||||
@include ellipsis-text();
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
|
||||
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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import { getLowerThirdOptions } from './lowerThird.options';
|
||||
|
||||
import './LowerThird.scss';
|
||||
|
||||
type LowerOptions = {
|
||||
@@ -182,7 +183,7 @@ export default function LowerThird(props: LowerProps) {
|
||||
|
||||
return (
|
||||
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
||||
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} />
|
||||
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
|
||||
<div
|
||||
className={`container container--${playState}`}
|
||||
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 { 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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
|
||||
|
||||
import './MinimalTimer.scss';
|
||||
|
||||
interface MinimalTimerProps {
|
||||
@@ -161,7 +162,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
}}
|
||||
data-testid='minimal-timer'
|
||||
>
|
||||
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
|
||||
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
||||
{showEndMessage ? (
|
||||
<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 {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
@@ -19,8 +18,12 @@ import { titleVariants } from '../common/animation';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import { getPublicOptions } from './public.options';
|
||||
|
||||
import './Public.scss';
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
@@ -71,7 +74,7 @@ export default function Public(props: BackstageProps) {
|
||||
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<ViewParamsEditor paramFields={publicOptions} />
|
||||
<ViewParamsEditor viewOptions={publicOptions} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
@@ -83,31 +86,33 @@ export default function Public(props: BackstageProps) {
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{publicEventNow && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} />
|
||||
</motion.div>
|
||||
label='now'
|
||||
title={publicEventNow.title}
|
||||
secondary={secondaryTextNow}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicEventNext && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={publicEventNext.title}
|
||||
secondary={secondaryTextNext}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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;
|
||||
align-items: start;
|
||||
gap: 0.5em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--now {
|
||||
color: var(--studio-active-label, $red-active);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { isOntimeEvent, Playback } from 'ontime-types';
|
||||
import { millisToString, removeSeconds } from 'ontime-utils';
|
||||
|
||||
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 useFitText from '../../../common/hooks/useFitText';
|
||||
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 { isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import { getStudioClockOptions } from './studioClock.options';
|
||||
import { secondsInMillis, trimRundown } from './studioClock.utils';
|
||||
|
||||
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?
|
||||
// deferring rendering seems to affect styling (font and useFitText)
|
||||
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 secondsIndicators = [...Array(60).keys()];
|
||||
@@ -75,7 +75,7 @@ export default function StudioClock(props: StudioClockProps) {
|
||||
|
||||
return (
|
||||
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
||||
<ViewParamsEditor paramFields={studioClockOptions} />
|
||||
<ViewParamsEditor viewOptions={studioClockOptions} />
|
||||
<div className='clock-container'>
|
||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</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);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
grid-template-areas:
|
||||
'clock'
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
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 { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
@@ -25,6 +24,8 @@ import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import { getTimerOptions } from './timer.options';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
// motion
|
||||
@@ -43,6 +44,8 @@ const titleVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface TimerProps {
|
||||
customFields: CustomFields;
|
||||
eventNext: OntimeEvent | null;
|
||||
@@ -156,7 +159,7 @@ export default function Timer(props: TimerProps) {
|
||||
|
||||
return (
|
||||
<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'} />
|
||||
{!userOptions.hideMessage && (
|
||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||
@@ -211,31 +214,33 @@ export default function Timer(props: TimerProps) {
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{eventNow?.title && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
|
||||
</motion.div>
|
||||
label='now'
|
||||
title={mainFieldNow}
|
||||
secondary={secondaryTextNow}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext?.title && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
<MotionTitleCard
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={mainFieldNext}
|
||||
secondary={secondaryTextNext}
|
||||
className='event next'
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -15,3 +15,9 @@
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ellipsis-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ $timer-finished-color: $playback-negative;
|
||||
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-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 { langIt } from './languages/it';
|
||||
import { langNo } from './languages/no';
|
||||
import { langPl } from './languages/pl';
|
||||
import { langPt } from './languages/pt';
|
||||
import { langSv } from './languages/sv';
|
||||
|
||||
@@ -20,6 +21,7 @@ const translationsList = {
|
||||
no: langNo,
|
||||
pt: langPt,
|
||||
sv: langSv,
|
||||
pl: langPl,
|
||||
};
|
||||
|
||||
interface TranslationContextValue {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langDe: TranslationObject = {
|
||||
'common.end_time': 'Endzeit',
|
||||
'common.expected_finish': 'Voraussichtliches Ende',
|
||||
'common.expected_finish': 'Erwartetes Ende',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Jetzt',
|
||||
'common.next': 'Nächste',
|
||||
'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.started_at': 'Gestartet am',
|
||||
'common.time_now': 'Aktuelle Zeit',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export const langEn = {
|
||||
'common.end_time': 'End Time',
|
||||
'common.expected_finish': 'Expected Finish',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Now',
|
||||
'common.next': 'Next',
|
||||
'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.started_at': 'Started At',
|
||||
'common.time_now': 'Time now',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langEs: TranslationObject = {
|
||||
'common.end_time': 'Hora de finalización',
|
||||
'common.expected_finish': 'Finalización esperada',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Ahora',
|
||||
'common.next': 'Siguiente',
|
||||
'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.started_at': 'Iniciado en',
|
||||
'common.time_now': 'Ahora',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langFr: TranslationObject = {
|
||||
'common.end_time': 'Termine à',
|
||||
'common.expected_finish': 'Fin estimée à',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Maintenant',
|
||||
'common.next': 'A suivre',
|
||||
'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.started_at': 'Commencé à',
|
||||
'common.time_now': 'Heure',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langIt: TranslationObject = {
|
||||
'common.end_time': 'Ora di Fine',
|
||||
'common.expected_finish': 'Fine Prevista',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Adesso',
|
||||
'common.next': 'Prossimo',
|
||||
'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.started_at': 'Iniziato Alle',
|
||||
'common.time_now': 'Ora attuale',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langNo: TranslationObject = {
|
||||
'common.end_time': 'Sluttid',
|
||||
'common.expected_finish': 'Forventet slutt',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Nå',
|
||||
'common.next': 'Neste',
|
||||
'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.started_at': 'Startet',
|
||||
'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';
|
||||
|
||||
export const langPt: TranslationObject = {
|
||||
'common.end_time': 'Hora de término',
|
||||
'common.expected_finish': 'Término esperado',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Agora',
|
||||
'common.next': 'Próximo',
|
||||
'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.started_at': 'Iniciado em',
|
||||
'common.time_now': 'Hora atual',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langSv: TranslationObject = {
|
||||
'common.end_time': 'Sluttid',
|
||||
'common.expected_finish': 'Förväntat slut',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Nu',
|
||||
'common.next': 'Nästa',
|
||||
'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.started_at': 'Började vid',
|
||||
'common.time_now': 'Klockan nu',
|
||||
|
||||
@@ -61,6 +61,7 @@ export default defineConfig({
|
||||
additionalData: `
|
||||
@use './src/theme/ontimeColours' as *;
|
||||
@use './src/theme/ontimeStyles' as *;
|
||||
@use './src/theme/mixins' as *;
|
||||
`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -12,17 +12,18 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^28.3.3",
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.0.3"
|
||||
"prettier": "^3.0.3",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"dev": "cross-env NODE_ENV=development electron .",
|
||||
"dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
|
||||
|
||||
+11
-12
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.1",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -20,30 +20,29 @@
|
||||
"node-xlsx": "^0.23.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"steno": "^3.1.0",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"steno": "^4.0.2",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"esbuild": "^0.19.10",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"nodemon": "^2.0.20",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.4.3",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -51,9 +50,9 @@
|
||||
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
|
||||
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json",
|
||||
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject",
|
||||
"dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts",
|
||||
"dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts",
|
||||
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
|
||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||
"prebuild": "pnpm set:demoproject",
|
||||
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
|
||||
@@ -8,32 +8,17 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
|
||||
import { doesProjectExist, handleUploaded } from '../../services/project-service/projectServiceUtils.js';
|
||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
|
||||
import { appStateService } from '../../services/app-state-service/AppStateService.js';
|
||||
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||
// all fields are optional in validation
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
res.status(400).send({ message: 'No field found to patch' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body;
|
||||
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
|
||||
|
||||
const newData = await projectService.applyDataModel(patchDb);
|
||||
const newData = await projectService.patchCurrentProject(patchDb);
|
||||
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
@@ -52,27 +37,20 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
* or a 500 status with an error message in case of an exception.
|
||||
*/
|
||||
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||
const newProjectData: ProjectData = {
|
||||
title: req.body?.title ?? '',
|
||||
description: req.body?.description ?? '',
|
||||
publicUrl: req.body?.publicUrl ?? '',
|
||||
publicInfo: req.body?.publicInfo ?? '',
|
||||
backstageUrl: req.body?.backstageUrl ?? '',
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
|
||||
try {
|
||||
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
|
||||
const errors = projectService.validateProjectFiles({ newFilename: filename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: 'Project with title already exists' });
|
||||
}
|
||||
|
||||
const newProjectData: ProjectData = {
|
||||
title: req.body?.title ?? '',
|
||||
description: req.body?.description ?? '',
|
||||
publicUrl: req.body?.publicUrl ?? '',
|
||||
publicInfo: req.body?.publicInfo ?? '',
|
||||
backstageUrl: req.body?.backstageUrl ?? '',
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
|
||||
await projectService.createProjectFile(filename, newProjectData);
|
||||
const newFileName = await projectService.createProject(req.body.filename, newProjectData);
|
||||
|
||||
res.status(200).send({
|
||||
filename,
|
||||
filename: newFileName,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -85,10 +63,8 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
*/
|
||||
export async function projectDownload(req: Request, res: Response) {
|
||||
const { filename } = req.body;
|
||||
const pathToFile = join(resolveDbDirectory, filename);
|
||||
|
||||
// Check if the file exists before attempting to download
|
||||
if (!existsSync(pathToFile)) {
|
||||
const pathToFile = doesProjectExist(filename);
|
||||
if (!pathToFile) {
|
||||
return res.status(404).send({ message: `Project ${filename} not found.` });
|
||||
}
|
||||
|
||||
@@ -102,6 +78,7 @@ export async function projectDownload(req: Request, res: Response) {
|
||||
|
||||
/**
|
||||
* uploads, parses and applies the data from a given file
|
||||
* Pretty much loadProject but with the extra upload step
|
||||
*/
|
||||
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
if (!req.file) {
|
||||
@@ -110,23 +87,18 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
|
||||
}
|
||||
|
||||
try {
|
||||
const options = req.query;
|
||||
const { filename, path } = req.file;
|
||||
|
||||
await projectService.handleUploadedFile(path, filename);
|
||||
await projectService.applyProjectFile(filename, options);
|
||||
|
||||
const oscSettings = await DataProvider.getOsc();
|
||||
const httpSettings = await DataProvider.getHttp();
|
||||
|
||||
oscIntegration.init(oscSettings);
|
||||
httpIntegration.init(httpSettings);
|
||||
await handleUploaded(path, filename);
|
||||
await projectService.loadProjectFile(filename);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${filename}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message.startsWith('Project file')) {
|
||||
return res.status(403).send({ message });
|
||||
}
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
@@ -150,23 +122,16 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
|
||||
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const name = req.body.filename;
|
||||
if (!projectService.doesProjectExist(name)) {
|
||||
return res.status(404).send({ message: 'File not found' });
|
||||
}
|
||||
|
||||
await projectService.applyProjectFile(name);
|
||||
|
||||
const oscSettings = await DataProvider.getOsc();
|
||||
const httpSettings = await DataProvider.getHttp();
|
||||
|
||||
oscIntegration.init(oscSettings);
|
||||
httpIntegration.init(httpSettings);
|
||||
await projectService.loadProjectFile(name);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message.startsWith('Project file')) {
|
||||
return res.status(403).send({ message });
|
||||
}
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
@@ -182,16 +147,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
|
||||
* or a 500 status with an error message in case of an exception.
|
||||
*/
|
||||
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
// file to copy from
|
||||
const { filename } = req.params;
|
||||
// new file name
|
||||
const { newFilename } = req.body;
|
||||
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const { newFilename } = req.body;
|
||||
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
await projectService.duplicateProjectFile(filename, newFilename);
|
||||
|
||||
res.status(201).send({
|
||||
@@ -199,6 +160,10 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message.startsWith('Project file')) {
|
||||
return res.status(403).send({ message });
|
||||
}
|
||||
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
@@ -215,16 +180,9 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
|
||||
*/
|
||||
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const { filename: newFilename } = req.body;
|
||||
const { newFilename } = req.body;
|
||||
const { filename } = req.params;
|
||||
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
await projectService.renameProjectFile(filename, newFilename);
|
||||
|
||||
res.status(201).send({
|
||||
@@ -232,6 +190,10 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message.startsWith('Project file')) {
|
||||
return res.status(403).send({ message });
|
||||
}
|
||||
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
@@ -247,33 +209,32 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
|
||||
* or a 500 status with an error message in case of an exception.
|
||||
*/
|
||||
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
const { filename } = req.params;
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
|
||||
const { lastLoadedProject } = await appStateService.get();
|
||||
|
||||
if (lastLoadedProject === filename) {
|
||||
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
|
||||
}
|
||||
|
||||
const errors = projectService.validateProjectFiles({ filename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
await projectService.deleteProjectFile(filename);
|
||||
|
||||
res.status(204).send({
|
||||
res.status(200).send({
|
||||
message: `Deleted project ${filename}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (message === 'Cannot delete currently loaded project') {
|
||||
return res.status(403).send({ message });
|
||||
}
|
||||
if (message === 'Project file not found') {
|
||||
return res.status(404).send({ message });
|
||||
}
|
||||
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInfo(_req: Request, res: Response<GetInfo | ErrorResponse>) {
|
||||
try {
|
||||
const info = await projectService.getInfo();
|
||||
res.status(200).send(info);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInfo(_req: Request, res: Response<GetInfo>) {
|
||||
const info = await projectService.getInfo();
|
||||
res.status(200).send(info);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
validatePatchProject,
|
||||
validateFilenameBody,
|
||||
validateFilenameParam,
|
||||
validateNewFilenameBody,
|
||||
} from './db.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
@@ -31,8 +32,8 @@ router.post('/new', validateFilenameBody, validateNewProject, createProjectFile)
|
||||
router.get('/all', listProjects);
|
||||
|
||||
router.post('/load', validateFilenameBody, loadProject);
|
||||
router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
|
||||
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
|
||||
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
|
||||
|
||||
router.get('/info', getInfo);
|
||||
|
||||
@@ -26,13 +26,22 @@ export const validateNewProject = [
|
||||
* @description Validates request for pathing data in the project.
|
||||
*/
|
||||
export const validatePatchProject = [
|
||||
// Custom validator to ensure the body is not empty
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
if (Object.keys(req.body).length === 0) {
|
||||
return res.status(422).json({ errors: [{ msg: 'Request body cannot be empty' }] });
|
||||
}
|
||||
next();
|
||||
},
|
||||
|
||||
body('rundown').isArray().optional({ nullable: false }),
|
||||
body('project').isObject().optional({ nullable: false }),
|
||||
body('settings').isObject().optional({ nullable: false }),
|
||||
body('viewSettings').isObject().optional({ nullable: false }),
|
||||
body('aliases').isArray().optional({ nullable: false }),
|
||||
body('urlPresets').isArray().optional({ nullable: false }),
|
||||
body('customFields').isObject().optional({ nullable: false }),
|
||||
body('osc').isObject().optional({ nullable: false }),
|
||||
body('http').isObject().optional({ nullable: false }),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -41,6 +50,30 @@ export const validatePatchProject = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates request with newFilename in the body.
|
||||
*/
|
||||
export const validateNewFilenameBody = [
|
||||
body('newFilename')
|
||||
.exists()
|
||||
.isString()
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates request with filename in the body.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { existsSync } from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
|
||||
import { parseRundown } from '../../utils/parserFunctions.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
|
||||
let excelData: { name: string; data: unknown[][] }[] = [];
|
||||
@@ -42,11 +42,10 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
|
||||
const dataFromExcel = parseExcel(data, options);
|
||||
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
const rundown = parseRundown(dataFromExcel);
|
||||
const { rundown, customFields } = parseRundown(dataFromExcel);
|
||||
if (rundown.length === 0) {
|
||||
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
|
||||
}
|
||||
const customFields = parseCustomFields(dataFromExcel);
|
||||
|
||||
// clear the data
|
||||
excelData = [];
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ErrorResponse, HttpSettings } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
|
||||
const http = DataProvider.getHttp();
|
||||
const http = getDataProvider().getHttp();
|
||||
res.status(200).send(http);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorR
|
||||
|
||||
httpIntegration.init(httpSettings);
|
||||
// we persist the data after init to avoid persisting invalid data
|
||||
const result = await DataProvider.setHttp(httpSettings);
|
||||
const result = await getDataProvider().setHttp(httpSettings);
|
||||
res.send(result).status(200);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ErrorResponse, OSCSettings } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
|
||||
const osc = DataProvider.getOsc();
|
||||
const osc = getDataProvider().getOsc();
|
||||
res.status(200).send(osc);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function postOSC(req: Request, res: Response<OSCSettings | ErrorRes
|
||||
|
||||
oscIntegration.init(oscSettings);
|
||||
// we persist the data after init to avoid persisting invalid data
|
||||
const result = await DataProvider.setOsc(oscSettings);
|
||||
const result = await getDataProvider().setOsc(oscSettings);
|
||||
|
||||
res.send(result).status(200);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ErrorResponse, ProjectData } from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
|
||||
res.json(DataProvider.getProjectData());
|
||||
res.json(getDataProvider().getProjectData());
|
||||
}
|
||||
|
||||
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
|
||||
@@ -25,7 +26,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
|
||||
backstageInfo: req.body?.backstageInfo,
|
||||
endMessage: req.body?.endMessage,
|
||||
});
|
||||
const newData = await DataProvider.setProjectData(newEvent);
|
||||
const newData = await getDataProvider().setProjectData(newEvent);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import {
|
||||
ErrorResponse,
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
RundownCached,
|
||||
RundownPaginated,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@@ -13,8 +21,12 @@ import {
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
} from '../../services/rundown-service/RundownService.js';
|
||||
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import {
|
||||
getEventWithId,
|
||||
getNormalisedRundown,
|
||||
getPaginated,
|
||||
getRundown,
|
||||
} from '../../services/rundown-service/rundownUtils.js';
|
||||
|
||||
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
|
||||
const rundown = getRundown();
|
||||
@@ -26,6 +38,51 @@ export async function rundownGetNormalised(_req: Request, res: Response<RundownC
|
||||
res.json(cachedRundown);
|
||||
}
|
||||
|
||||
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
|
||||
const { eventId } = req.params;
|
||||
|
||||
try {
|
||||
const event = getEventWithId(eventId);
|
||||
|
||||
if (!event) {
|
||||
res.status(404).send({ message: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json(event);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function rundownGetPaginated(req: Request, res: Response<RundownPaginated | ErrorResponse>) {
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
if (limit == null && offset == null) {
|
||||
return res.json({
|
||||
rundown: getRundown(),
|
||||
total: getRundown().length,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let parsedOffset = Number(offset);
|
||||
if (Number.isNaN(parsedOffset)) {
|
||||
parsedOffset = 0;
|
||||
}
|
||||
let parsedLimit = Number(limit);
|
||||
if (Number.isNaN(parsedLimit)) {
|
||||
parsedLimit = Infinity;
|
||||
}
|
||||
const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
|
||||
|
||||
res.status(200).json(paginatedRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).json({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
rundownBatchPut,
|
||||
rundownDelete,
|
||||
rundownGetAll,
|
||||
rundownGetById,
|
||||
rundownGetNormalised,
|
||||
rundownGetPaginated,
|
||||
rundownPost,
|
||||
rundownPut,
|
||||
rundownReorder,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
paramsMustHaveEventId,
|
||||
rundownArrayOfIds,
|
||||
rundownBatchPutValidator,
|
||||
rundownGetPaginatedQueryParams,
|
||||
rundownPostValidator,
|
||||
rundownPutValidator,
|
||||
rundownReorderValidator,
|
||||
@@ -25,7 +28,9 @@ import {
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', rundownGetAll); // not used in Ontime frontend
|
||||
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
|
||||
router.get('/normalised', rundownGetNormalised);
|
||||
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
|
||||
|
||||
router.post('/', rundownPostValidator, rundownPost);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { body, param, validationResult } from 'express-validator';
|
||||
import { body, param, query, validationResult } from 'express-validator';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const rundownPostValidator = [
|
||||
@@ -75,3 +75,14 @@ export const rundownArrayOfIds = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const rundownGetPaginatedQueryParams = [
|
||||
query('offset').isNumeric().optional(),
|
||||
query('limit').isNumeric().optional(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ErrorResponse, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { extractPin } from '../../services/project-service/ProjectService.js';
|
||||
import { isDocker } from '../../setup/index.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
import { extractPin } from './settings.utils.js';
|
||||
|
||||
export async function getSettings(_req: Request, res: Response<Settings>) {
|
||||
const settings = DataProvider.getSettings();
|
||||
const settings = getDataProvider().getSettings();
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
@@ -27,7 +28,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settings = DataProvider.getSettings();
|
||||
const settings = getDataProvider().getSettings();
|
||||
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||
const serverPort = Number(req.body?.serverPort);
|
||||
@@ -57,7 +58,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
|
||||
language,
|
||||
serverPort,
|
||||
};
|
||||
await DataProvider.setSettings(newData);
|
||||
await getDataProvider().setSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Business logic for resolving a string
|
||||
*/
|
||||
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ErrorResponse, URLPreset } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failIsNotArray } from '../../utils/routerUtils.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { failIsNotArray } from '../../utils/routerUtils.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
|
||||
const presets = DataProvider.getUrlPresets();
|
||||
const presets = getDataProvider().getUrlPresets();
|
||||
res.status(200).send(presets as URLPreset[]);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
|
||||
alias: preset.alias,
|
||||
pathAndParams: preset.pathAndParams,
|
||||
}));
|
||||
await DataProvider.setUrlPresets(newPresets);
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import type { ErrorResponse, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
|
||||
const views = DataProvider.getViewSettings();
|
||||
const views = getDataProvider().getViewSettings();
|
||||
res.status(200).send(views);
|
||||
}
|
||||
|
||||
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newData = {
|
||||
dangerColor: req.body.dangerColor,
|
||||
endMessage: req.body?.endMessage ?? '',
|
||||
endMessage: req.body.endMessage,
|
||||
freezeEnd: req.body.freezeEnd,
|
||||
normalColor: req.body.normalColor,
|
||||
overrideStyles: req.body.overrideStyles,
|
||||
warningColor: req.body.warningColor,
|
||||
};
|
||||
await DataProvider.setViewSettings(newData);
|
||||
} as ViewSettings;
|
||||
await getDataProvider().setViewSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Request, Response, NextFunction } from 'express';
|
||||
* @description Validates object for POST /ontime/views
|
||||
*/
|
||||
export const validateViewSettings = [
|
||||
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
|
||||
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
|
||||
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
|
||||
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
|
||||
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
|
||||
check('dangerColor').exists().isString().trim().withMessage('dangerColor value must be string'),
|
||||
check('endMessage').exists().isString().trim().withMessage('endMessage value must be string'),
|
||||
check('freezeEnd').exists().isBoolean().withMessage('freezeEnd value must be boolean'),
|
||||
check('normalColor').exists().isString().trim().withMessage('normalColor value must be string'),
|
||||
check('overrideStyles').exists().isBoolean().withMessage('overrideStyles value must be boolean'),
|
||||
check('warningColor').exists().isString().trim().withMessage('warningColor value must be string'),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -55,16 +55,17 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
throw new Error('Invalid property or value');
|
||||
}
|
||||
|
||||
const newObjectProperty = parseProperty(property, value);
|
||||
// parseProperty is async because of the data lock
|
||||
parseProperty(property, value).then((newObjectProperty) => {
|
||||
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
|
||||
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
|
||||
|
||||
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
|
||||
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
|
||||
|
||||
if (patchEvent.custom && newObjectProperty.custom) {
|
||||
Object.assign(patchEvent.custom, newObjectProperty.custom);
|
||||
} else {
|
||||
Object.assign(patchEvent, newObjectProperty);
|
||||
}
|
||||
if (patchEvent.custom && newObjectProperty.custom) {
|
||||
Object.assign(patchEvent.custom, newObjectProperty.custom);
|
||||
} else {
|
||||
Object.assign(patchEvent, newObjectProperty);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (shouldThrottle) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
|
||||
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { editEvent } from '../services/rundown-service/RundownService.js';
|
||||
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
|
||||
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} value time amount in seconds
|
||||
* @returns {number} time in milliseconds clamped to 0 and max duration
|
||||
*/
|
||||
function clampDuration(value: number) {
|
||||
function clampDuration(value: number): number {
|
||||
const valueInMillis = value * MILLIS_PER_SECOND;
|
||||
if (valueInMillis > maxDuration || valueInMillis < 0) {
|
||||
throw new Error('Times should be from 0 to 23:59:59');
|
||||
@@ -42,10 +42,11 @@ const propertyConversion = {
|
||||
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
};
|
||||
|
||||
export function parseProperty(property: string, value: unknown) {
|
||||
export async function parseProperty(property: string, value: unknown) {
|
||||
if (property.startsWith('custom:')) {
|
||||
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
|
||||
if (!(customKey in DataProvider.getCustomFields())) {
|
||||
const customFields = getDataProvider().getCustomFields();
|
||||
if (!(customKey in customFields)) {
|
||||
throw new Error(`Custom field ${customKey} not found`);
|
||||
}
|
||||
const parserFn = propertyConversion.custom;
|
||||
|
||||
+19
-16
@@ -1,4 +1,4 @@
|
||||
import { HttpSettings, LogOrigin, OSCSettings, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
srcDirectory,
|
||||
environment,
|
||||
isProduction,
|
||||
resolveDbPath,
|
||||
resolveExternalsDirectory,
|
||||
resolveStylesDirectory,
|
||||
resolvedPath,
|
||||
clearUploadfolder,
|
||||
resolvePublicDirectoy,
|
||||
} from './setup/index.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight } from './utils/console.js';
|
||||
@@ -28,8 +27,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
|
||||
|
||||
// Import adapters
|
||||
import { socket } from './adapters/WebsocketAdapter.js';
|
||||
import { DataProvider } from './classes/data-provider/DataProvider.js';
|
||||
import { dbLoadingProcess } from './setup/loadDb.js';
|
||||
import { getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
|
||||
// Services
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
@@ -44,6 +42,10 @@ import { messageService } from './services/message-service/MessageService.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { getState } from './stores/runtimeState.js';
|
||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||
|
||||
// Utilities
|
||||
import { clearUploadfolder } from './utils/upload.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
|
||||
|
||||
@@ -53,8 +55,8 @@ consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
const canLog = isProduction;
|
||||
if (!canLog) {
|
||||
console.log(`Ontime running in ${environment} environment`);
|
||||
console.log(`Ontime directory at ${srcDirectory} `);
|
||||
console.log(`Ontime database at ${resolveDbPath}`);
|
||||
console.log(`Ontime source directory at ${srcDirectory} `);
|
||||
console.log(`Ontime public directory at ${resolvePublicDirectoy} `);
|
||||
}
|
||||
|
||||
// Create express APP
|
||||
@@ -147,10 +149,11 @@ const checkStart = (currentState: OntimeStartOrder) => {
|
||||
|
||||
export const initAssets = async () => {
|
||||
checkStart(OntimeStartOrder.InitAssets);
|
||||
await dbLoadingProcess;
|
||||
await clearUploadfolder();
|
||||
populateStyles();
|
||||
populateDemo();
|
||||
const project = await initialiseProject();
|
||||
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -160,8 +163,7 @@ export const startServer = async (
|
||||
escalateErrorFn?: (error: string) => void,
|
||||
): Promise<{ message: string; serverPort: number }> => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
|
||||
const { serverPort } = DataProvider.getSettings();
|
||||
const { serverPort } = getDataProvider().getSettings();
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
socket.init(expressServer);
|
||||
@@ -192,8 +194,8 @@ export const startServer = async (
|
||||
logger.init(escalateErrorFn);
|
||||
|
||||
// initialise rundown service
|
||||
const persistedRundown = DataProvider.getRundown();
|
||||
const persistedCustomFields = DataProvider.getCustomFields();
|
||||
const persistedRundown = getDataProvider().getRundown();
|
||||
const persistedCustomFields = getDataProvider().getCustomFields();
|
||||
initRundown(persistedRundown, persistedCustomFields);
|
||||
|
||||
// load restore point if it exists
|
||||
@@ -208,8 +210,9 @@ export const startServer = async (
|
||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||
const nif = getNetworkInterfaces();
|
||||
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
|
||||
for (const key of Object.keys(nif)) {
|
||||
consoleSuccess(`Network: http://${nif[key].address}:${serverPort}/editor`);
|
||||
for (const key in nif) {
|
||||
const address = nif[key].address;
|
||||
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,11 +225,11 @@ export const startServer = async (
|
||||
/**
|
||||
* starts integrations
|
||||
*/
|
||||
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
|
||||
export const startIntegrations = async () => {
|
||||
checkStart(OntimeStartOrder.InitIO);
|
||||
|
||||
// if a config is not provided, we use the persisted one
|
||||
const { osc, http } = config ?? DataProvider.getData();
|
||||
const { osc, http } = getDataProvider().getData();
|
||||
|
||||
if (osc) {
|
||||
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
|
||||
|
||||
@@ -4,11 +4,11 @@ import { generateId, millisToString } from 'ontime-utils';
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { isProduction } from '../setup/index.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { consoleSubdued, consoleRed } from '../utils/console.js';
|
||||
import { consoleSubdued, consoleError } from '../utils/console.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
private escalateErrorFn: (error: string) => void | null;
|
||||
private escalateErrorFn: ((error: string) => void) | null;
|
||||
private canLog = false;
|
||||
|
||||
constructor() {
|
||||
@@ -20,7 +20,7 @@ class Logger {
|
||||
/**
|
||||
* Enabling setup logger after init
|
||||
*/
|
||||
init(escalateErrorFn: (error: string) => void) {
|
||||
init(escalateErrorFn?: (error: string) => void) {
|
||||
// flush logs from queue
|
||||
this.queue.forEach((log) => {
|
||||
this._push(log);
|
||||
@@ -47,7 +47,7 @@ class Logger {
|
||||
private _push(log: Log) {
|
||||
if (this.canLog || log.level === LogLevel.Severe) {
|
||||
if (log.level === LogLevel.Severe) {
|
||||
consoleRed(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
consoleError(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
} else {
|
||||
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import {
|
||||
ProjectData,
|
||||
OntimeRundown,
|
||||
@@ -14,117 +10,165 @@ import {
|
||||
URLPreset,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../setup/loadDb.js';
|
||||
import type { Low } from 'lowdb';
|
||||
import { JSONFilePreset } from 'lowdb/node';
|
||||
|
||||
import { isProduction, isTest } from '../../setup/index.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
import { isTest } from '../../setup/index.js';
|
||||
|
||||
type ReadonlyPromise<T> = Promise<Readonly<T>>;
|
||||
|
||||
export class DataProvider {
|
||||
static getData() {
|
||||
return data;
|
||||
}
|
||||
let db = {} as Low<DatabaseModel>;
|
||||
|
||||
static async setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
|
||||
data.project = { ...data.project, ...newData };
|
||||
this.persist();
|
||||
return data.project;
|
||||
}
|
||||
|
||||
static getProjectData(): Readonly<ProjectData> {
|
||||
return data.project;
|
||||
}
|
||||
|
||||
static async setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
|
||||
data.customFields = { ...newData };
|
||||
this.persist();
|
||||
return data.customFields;
|
||||
}
|
||||
|
||||
static getCustomFields(): Readonly<CustomFields> {
|
||||
return data.customFields;
|
||||
}
|
||||
|
||||
static async setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
|
||||
data.rundown = [...newData];
|
||||
this.persist();
|
||||
return data.rundown;
|
||||
}
|
||||
|
||||
static getSettings(): Readonly<Settings> {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static async setSettings(newData: Settings): ReadonlyPromise<Settings> {
|
||||
data.settings = { ...newData };
|
||||
this.persist();
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static getOsc(): Readonly<OSCSettings> {
|
||||
return data.osc;
|
||||
}
|
||||
|
||||
static getHttp(): Readonly<HttpSettings> {
|
||||
return data.http;
|
||||
}
|
||||
|
||||
static getUrlPresets(): Readonly<URLPreset[]> {
|
||||
return data.urlPresets;
|
||||
}
|
||||
|
||||
static async setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
|
||||
data.urlPresets = newData;
|
||||
this.persist();
|
||||
return data.urlPresets;
|
||||
}
|
||||
|
||||
static getViewSettings(): Readonly<ViewSettings> {
|
||||
return data.viewSettings;
|
||||
}
|
||||
|
||||
static async setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
|
||||
data.viewSettings = { ...newData };
|
||||
this.persist();
|
||||
return data.viewSettings;
|
||||
}
|
||||
|
||||
static async setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
|
||||
data.osc = { ...newData };
|
||||
this.persist();
|
||||
return data.osc;
|
||||
}
|
||||
|
||||
static async setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
|
||||
data.http = { ...newData };
|
||||
this.persist();
|
||||
return data.http;
|
||||
}
|
||||
|
||||
static getRundown(): Readonly<OntimeRundown> {
|
||||
return data.rundown;
|
||||
}
|
||||
|
||||
private static async persist() {
|
||||
if (isTest) {
|
||||
return;
|
||||
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
|
||||
if (!isProduction) {
|
||||
if (!isPath(filePath)) {
|
||||
consoleError(filePath);
|
||||
consoleError(new Error('initPersistence should be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
await db.write();
|
||||
}
|
||||
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
|
||||
|
||||
static async mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
|
||||
const mergedData = safeMerge(data, newData);
|
||||
data.project = mergedData.project;
|
||||
data.settings = mergedData.settings;
|
||||
data.viewSettings = mergedData.viewSettings;
|
||||
data.osc = mergedData.osc;
|
||||
data.http = mergedData.http;
|
||||
data.urlPresets = mergedData.urlPresets;
|
||||
data.customFields = mergedData.customFields;
|
||||
data.rundown = mergedData.rundown;
|
||||
// Read the database to initialize it
|
||||
newDb.data = fallbackData;
|
||||
await newDb.write();
|
||||
await newDb.read();
|
||||
|
||||
this.persist();
|
||||
|
||||
return data;
|
||||
}
|
||||
db = newDb;
|
||||
}
|
||||
|
||||
export function getDataProvider() {
|
||||
if (db === null) throw new Error('Database not initialized');
|
||||
|
||||
return {
|
||||
getData,
|
||||
setProjectData,
|
||||
getProjectData,
|
||||
setCustomFields,
|
||||
getCustomFields,
|
||||
setRundown,
|
||||
getSettings,
|
||||
setSettings,
|
||||
getOsc,
|
||||
getHttp,
|
||||
getUrlPresets,
|
||||
setUrlPresets,
|
||||
getViewSettings,
|
||||
setViewSettings,
|
||||
setOsc,
|
||||
setHttp,
|
||||
getRundown,
|
||||
mergeIntoData,
|
||||
};
|
||||
}
|
||||
|
||||
function getData(): Readonly<DatabaseModel> {
|
||||
return db.data;
|
||||
}
|
||||
|
||||
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
|
||||
db.data.project = { ...db.data.project, ...newData };
|
||||
await persist();
|
||||
return db.data.project;
|
||||
}
|
||||
|
||||
function getProjectData(): Readonly<ProjectData> {
|
||||
return db.data.project;
|
||||
}
|
||||
|
||||
async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
|
||||
db.data.customFields = { ...newData };
|
||||
await persist();
|
||||
return db.data.customFields;
|
||||
}
|
||||
|
||||
function getCustomFields(): Readonly<CustomFields> {
|
||||
return db.data.customFields;
|
||||
}
|
||||
|
||||
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
|
||||
db.data.rundown = [...newData];
|
||||
await persist();
|
||||
return db.data.rundown;
|
||||
}
|
||||
|
||||
function getSettings(): Readonly<Settings> {
|
||||
return db.data.settings;
|
||||
}
|
||||
|
||||
async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
|
||||
db.data.settings = { ...newData };
|
||||
await persist();
|
||||
return db.data.settings;
|
||||
}
|
||||
|
||||
function getOsc(): Readonly<OSCSettings> {
|
||||
return db.data.osc;
|
||||
}
|
||||
|
||||
function getHttp(): Readonly<HttpSettings> {
|
||||
return db.data.http;
|
||||
}
|
||||
|
||||
function getUrlPresets(): Readonly<URLPreset[]> {
|
||||
return db.data.urlPresets;
|
||||
}
|
||||
|
||||
async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
|
||||
db.data.urlPresets = newData;
|
||||
await persist();
|
||||
return db.data.urlPresets;
|
||||
}
|
||||
|
||||
function getViewSettings(): Readonly<ViewSettings> {
|
||||
return db.data.viewSettings;
|
||||
}
|
||||
|
||||
async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
|
||||
db.data.viewSettings = { ...newData };
|
||||
await persist();
|
||||
return db.data.viewSettings;
|
||||
}
|
||||
|
||||
async function setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
|
||||
db.data.osc = { ...newData };
|
||||
await persist();
|
||||
return db.data.osc;
|
||||
}
|
||||
|
||||
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
|
||||
db.data.http = { ...newData };
|
||||
await persist();
|
||||
return db.data.http;
|
||||
}
|
||||
|
||||
function getRundown(): Readonly<OntimeRundown> {
|
||||
return db.data.rundown;
|
||||
}
|
||||
|
||||
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
|
||||
const mergedData = safeMerge(db.data, newData);
|
||||
db.data.project = mergedData.project;
|
||||
db.data.settings = mergedData.settings;
|
||||
db.data.viewSettings = mergedData.viewSettings;
|
||||
db.data.osc = mergedData.osc;
|
||||
db.data.http = mergedData.http;
|
||||
db.data.urlPresets = mergedData.urlPresets;
|
||||
db.data.customFields = mergedData.customFields;
|
||||
db.data.rundown = mergedData.rundown;
|
||||
|
||||
await persist();
|
||||
return db.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles persisting data to file
|
||||
*/
|
||||
async function persist() {
|
||||
if (isTest) return;
|
||||
await db.write();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Merges two data objects
|
||||
* @param {object} existing
|
||||
* @param {object} newData
|
||||
* Merges a partial ontime project into a given ontime project
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
||||
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {};
|
||||
|
||||
return {
|
||||
|
||||
+9
-3
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, OntimeRundown, Settings, URLPreset } from 'ontime-types';
|
||||
import { DatabaseModel, OntimeRundown, Settings, URLPreset, ViewSettings } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -49,7 +49,7 @@ describe('safeMerge', () => {
|
||||
} as DatabaseModel;
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
const mergedData = safeMerge(existing, undefined);
|
||||
const mergedData = safeMerge(existing, {});
|
||||
expect(mergedData).toEqual(existing);
|
||||
});
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('safeMerge', () => {
|
||||
rundown: [],
|
||||
project: {
|
||||
title: '',
|
||||
description: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
@@ -153,8 +154,9 @@ describe('safeMerge', () => {
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: '',
|
||||
},
|
||||
} as ViewSettings,
|
||||
urlPresets: [],
|
||||
customFields: {},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
@@ -163,6 +165,10 @@ describe('safeMerge', () => {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
http: {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
} as DatabaseModel;
|
||||
|
||||
const newData = {
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-console */
|
||||
import { consoleHighlight, consoleRed } from './utils/console.js';
|
||||
import { consoleHighlight, consoleError } from './utils/console.js';
|
||||
import { initAssets, startIntegrations, startServer } from './app.js';
|
||||
|
||||
async function startOntime() {
|
||||
@@ -16,7 +16,7 @@ async function startOntime() {
|
||||
consoleHighlight('Request: Start integrations...');
|
||||
await startIntegrations();
|
||||
} catch (error) {
|
||||
consoleRed(`Request failed: ${error}`);
|
||||
consoleError(`Request failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||
|
||||
export const demoDb: DatabaseModel = {
|
||||
rundown: [
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '32d31',
|
||||
cue: 'SF1.01',
|
||||
title: 'Albania',
|
||||
note: 'SF1.01',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 36000000,
|
||||
timeEnd: 37200000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Sekret',
|
||||
artist: 'Ronela Hajati',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '21cd2',
|
||||
cue: 'SF1.02',
|
||||
title: 'Latvia',
|
||||
note: 'SF1.02',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 37500000,
|
||||
timeEnd: 38700000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Eat Your Salad',
|
||||
artist: 'Citi Zeni',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '0b371',
|
||||
cue: 'SF1.03',
|
||||
title: 'Lithuania',
|
||||
note: 'SF1.03',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 39000000,
|
||||
timeEnd: 40200000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Sentimentai',
|
||||
artist: 'Monika Liu',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '3cd28',
|
||||
cue: 'SF1.04',
|
||||
title: 'Switzerland',
|
||||
note: 'SF1.04',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 40500000,
|
||||
timeEnd: 41700000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Boys Do Cry',
|
||||
artist: 'Marius Bear',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'e457f',
|
||||
cue: 'SF1.05',
|
||||
title: 'Slovenia',
|
||||
note: 'SF1.05',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 42000000,
|
||||
timeEnd: 43200000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Disko',
|
||||
artist: 'LPS',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Block,
|
||||
id: '01e85',
|
||||
title: 'Lunch break',
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1c420',
|
||||
cue: 'SF1.06',
|
||||
title: 'Ukraine',
|
||||
note: 'SF1.06',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 47100000,
|
||||
timeEnd: 48300000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Stefania',
|
||||
artist: 'Kalush Orchestra',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'b7737',
|
||||
cue: 'SF1.07',
|
||||
title: 'Bulgaria',
|
||||
note: 'SF1.07',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 48600000,
|
||||
timeEnd: 49800000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Intention',
|
||||
artist: 'Intelligent Music Project',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'd3a80',
|
||||
cue: 'SF1.08',
|
||||
title: 'Netherlands',
|
||||
note: 'SF1.08',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 50100000,
|
||||
timeEnd: 51300000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'De Diepte',
|
||||
artist: 'S10',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '8276c',
|
||||
cue: 'SF1.09',
|
||||
title: 'Moldova',
|
||||
note: 'SF1.09',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 51600000,
|
||||
timeEnd: 52800000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Trenuletul',
|
||||
artist: 'Zdob si Zdub',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2340b',
|
||||
cue: 'SF1.10',
|
||||
title: 'Portugal',
|
||||
note: 'SF1.10',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 53100000,
|
||||
timeEnd: 54300000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Saudade Saudade',
|
||||
artist: 'Maro',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Block,
|
||||
id: 'cb90b',
|
||||
title: 'Afternoon break',
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '503c4',
|
||||
cue: 'SF1.11',
|
||||
title: 'Croatia',
|
||||
note: 'SF1.11',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 56100000,
|
||||
timeEnd: 57300000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Guilty Pleasure',
|
||||
artist: 'Mia Dimsic',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '5e965',
|
||||
cue: 'SF1.12',
|
||||
title: 'Denmark',
|
||||
note: 'SF1.12',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
|
||||
timeStart: 57600000,
|
||||
timeEnd: 58800000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'The Show',
|
||||
artist: 'Reddi',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'bab4a',
|
||||
cue: 'SF1.13',
|
||||
title: 'Austria',
|
||||
note: 'SF1.13',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 59100000,
|
||||
timeEnd: 60300000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Halo',
|
||||
artist: 'LUM!X & Pia Maria',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: 'd3eb1',
|
||||
cue: 'SF1.14',
|
||||
title: 'Greece',
|
||||
note: 'SF1.14',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 60600000,
|
||||
timeEnd: 61800000,
|
||||
duration: 1200000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
song: 'Die Together',
|
||||
artist: 'Amanda Tenfjord',
|
||||
},
|
||||
},
|
||||
],
|
||||
project: {
|
||||
title: 'Eurovision Song Contest',
|
||||
description: 'Turin 2022',
|
||||
publicUrl: 'www.getontime.no',
|
||||
publicInfo: 'Rehearsal Schedule - Turin 2022',
|
||||
backstageUrl: 'www.github.com/cpvalente/ontime',
|
||||
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: '3.3.2',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
freezeEnd: false,
|
||||
normalColor: '#ffffffcc',
|
||||
overrideStyles: false,
|
||||
warningColor: '#FFAB33',
|
||||
},
|
||||
customFields: {
|
||||
song: {
|
||||
label: 'Song',
|
||||
type: 'string',
|
||||
colour: '#339E4E',
|
||||
},
|
||||
artist: {
|
||||
label: 'Artist',
|
||||
type: 'string',
|
||||
colour: '#3E75E8',
|
||||
},
|
||||
},
|
||||
urlPresets: [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'test',
|
||||
pathAndParams: 'lower?bg=ff2&text=f00&size=0.6&transition=5',
|
||||
},
|
||||
],
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: true,
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
http: {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
};
|
||||
@@ -8,12 +8,12 @@ import { timerConfig } from '../config/config.js';
|
||||
* Service manages Ontime's main timer
|
||||
*/
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timer;
|
||||
private readonly _interval: NodeJS.Timeout;
|
||||
/** how often we recalculate */
|
||||
static _refreshInterval: number;
|
||||
|
||||
/** when timer will be finished */
|
||||
private endCallback: NodeJS.Timer;
|
||||
private endCallback: NodeJS.Timeout;
|
||||
|
||||
private onUpdateCallback: (updateResult: UpdateResult) => void;
|
||||
|
||||
|
||||
@@ -1558,7 +1558,7 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(null);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('handles loaded event', () => {
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
|
||||
import { appStatePath, isTest } from '../../setup/index.js';
|
||||
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
|
||||
interface Config {
|
||||
lastLoadedProject: string;
|
||||
interface AppState {
|
||||
lastLoadedProject?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service manages Ontime's runtime memory between boots
|
||||
*/
|
||||
const adapter = new JSONFile<AppState>(appStatePath);
|
||||
const config = new Low<AppState>(adapter, {});
|
||||
|
||||
class AppStateService {
|
||||
private config: Low<Config>;
|
||||
private pathToFile: string;
|
||||
|
||||
constructor(appStatePath: string) {
|
||||
this.pathToFile = appStatePath;
|
||||
const adapter = new JSONFile<Config>(this.pathToFile);
|
||||
this.config = new Low<Config>(adapter, null);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.config.read();
|
||||
await this.config.write();
|
||||
}
|
||||
|
||||
async get(): Promise<Config> {
|
||||
await this.config.read();
|
||||
return this.config.data;
|
||||
}
|
||||
|
||||
async updateDatabaseConfig(filename: string): Promise<void> {
|
||||
if (isTest) return;
|
||||
|
||||
this.config.data.lastLoadedProject = filename;
|
||||
await this.config.write();
|
||||
}
|
||||
export async function isLastLoadedProject(projectName: string): Promise<boolean> {
|
||||
const lastLoaded = await getLastLoadedProject();
|
||||
return lastLoaded === projectName;
|
||||
}
|
||||
|
||||
export const appStateService = new AppStateService(appStatePath);
|
||||
export async function getLastLoadedProject(): Promise<string | undefined> {
|
||||
// in test environment, we want to start the demo project
|
||||
if (isTest) return;
|
||||
|
||||
await config.read();
|
||||
return config.data.lastLoadedProject;
|
||||
}
|
||||
|
||||
export async function setLastLoadedProject(filename: string): Promise<void> {
|
||||
if (isTest) return;
|
||||
if (!isProduction) {
|
||||
if (isPath(filename)) {
|
||||
consoleError(filename);
|
||||
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
config.data.lastLoadedProject = filename;
|
||||
await config.write();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type GetTimeFn = () => number;
|
||||
|
||||
export class AuxTimerService {
|
||||
private timer: SimpleTimer;
|
||||
private interval: NodeJS.Timer | null = null;
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
private emit: EmitFn;
|
||||
private getTime: GetTimeFn;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user