* Refactor/ts sockets (#210)

* refactor(ts): create socket subscription service
* refactor(ts-sockets): useSubscription hook
* refactor(ts-sockets): extract colour selection to utility

* Refactor/socket controller (#212)

* refactor(socket): dictionary cleanup
* refactor(socket): detangle socket from EventTimer
* refactor(socket): poll object
* refactor(socket): wip, extract data responsibilities to provider
* fix: issue with onAir
* refactor: create data provider and validation utils
* refactor: remove deprecated endpoint
* refactor: detangle and validate ontime controller
* refactor: detangle and validate events controller
* refactor: validate routers
* refactor: handle post failure in modals

* chore: upgrade electron (#215)

* Fix: issue with text colour (#214)
* fix: adjust text colour from context
* fix: issue with wrong proptype
* chore: upgrade electron
* chore: declare node version
* chore: upgrade node actions

* Feat/55 v2 (#225)

* chore: upgrade relevant libraries
* feat(skip): add skip styling to paginated items
* chore: upgrade relevant libraries
* Fix: issue with text colour (#214)
* fix: adjust text colour from context
* fix: issue with wrong proptype
* fix issue with vite migration (#217)
* hotfix: 1.8.2 issues vite migration
* fix: file import options
* feat(55): styling
* refactor: remove onhover option for entry block
* refactor: relocate logging provider
* refactor: convert to typescript
* feat(55): add event editor
* refactor: extract event actions
* fix: bad import
* feat(55): redesign components
* fix: issues with not awaiting async
* refactor: replace socket with subscription
* refactor: remove unused
* style: small tweaks
* refactor: extract event actions
* refactor: cleanup debug
* refactor: chakra imports
* fix: optimistic mutations
* refactor: handle promise rejections
* refactor: validation
* fix: rq optimistic mutations
* feat: add playback feedback to block
* refactor: no optimistic adding of events
* refactor: simplify cursor state
* styles: cleanup editor style
* refactor: cleanup duration update
* fix: revert package upgrade (issues with vitest)
* fix: prevent cyclic imports
* refactor: extract data fetcher
* refactor: typescript migration
* chore: update tests

* fix: remove problematic (#228)

* styles: v2 (#229)

* style: revise selected style
* style: revise editor styles
* style: revise viewer styles
* chore: update tests

* v2:feat (#231)

* style: element relationship
* feat: utility to add first element
* refactor: organise dir
* ux: deleting element closes drawer
* fix: issue with escaped characters
* fix: issue with overflow in events list
* fix: prevent event timer from receiving non event
* style: prevent small shift on class change
* style: visually detach event editor
* fix: entry block knows last event
* fix: skip event timer updates on non events
* fix: prevent electron OPENGL error
* style: cleanup text styles
* style: unify input styles
* refactor: simplify style and composition
* refactor: tweaks on style + ts
* refactor: add validation to events post
* fix: handle non events in event timer
* refactor: cleanup unused

* V2 styles part2 (#233)

* style: create tag to display network messages
* style: create button style for playback
* style: review style for QuickEntry
* style: review style for Info panel
* style: review style for Event blocks
* style: review style for Message control panel
* style: review style for Playback control
* style: review application styles
* style: review block styles
* refactor: TapButton has active state
* style: small style tweaks
* refactor: type checks

* V2 message manager (#234)

* refactor: server run script
* refactor: extract message control feature
* refactor: socket usage for message control feature
* refactor: migrate e2e testing to playwright
* chore: add feature tests
* refactor: add validation on socket controller
* chore: jest tests server logic
* chore: update tests
* chore: run playwright tests on pull_requests

* V2 rundown manager (#237)

* refactor: migrate eventlist > rundown

* hotfix: fix object naming error on excel import (#252)

* feat: V2 ontime menu (#259)

* V2 socket store (#254)

* refactor: create socket utility
* refactor: ensure a single subscription to topics
* refactor: replace usages of socket object with subscription store handler

* refactor: v2 event loader (#260)

* refactor: extract jest config

* refactor: rename params in reoder endpoint

* refactor: avoid app exports

* refactor: small code smells

* refactor: run development server

* refactor: extract rundown service and event loader logic

* refactor: rename events > rundown

* refactor: code style

* fix: handle external change of title

* refactor: cleanup dictionary

* refactor: rename MessageService

* refactor: use eventID for operations

* refactor: migrate OSC controller to service

* refactor: migrate Socket controller to service

* refactor: migrate HTTP controller to service

* refactor: extract logic into discrete services

* refactor: remove rundown from event timer

* refactor: remove duplicate

* refactor: remove unused

* chore: update tests

* refactor: run test CI on master or manual run

* lint: reorder imports

* refactor: ensure consistent routing

* V2 event block (#261)

* feat: implement progress bar on running event

* refactor: use event action hook

* refactor: align backend data

* v2 styling (#263)

* feat: implement progress bar on running event

* refactor: use event action hook

* refactor: align backend data

* style: v2 style tweaks

* style: v2 style tweaks + ts <Info>

* fix: fix delay increment calls

* style: v2 style tweaks + ts <Playback>

* style: v2 style tweaks + ts <EventEditor>

* style: v2 style tweaks + ts <MessageControl>

* fix: naming issue with message control socket endpoint

* chore: upgrade style dependencies

* style: v2 style tweaks + ts <MenuBar>

* style: v2 style tweaks + ts <RundownMenu>

* style: v2 style tweaks + ts <Playback>

* fix: add selected and next information to EventTimer

* style: v2 style tweaks + ts <EventBlock>

* style: v2 style tweaks + ts <DelayBlock>

* style: v2 style tweaks + ts <BlockBlock>

* style: v2 style tweaks + ts <QuickEntry>

* refactor: extract <TextInput> logic

* chore: upgrade build dependencies

* chore: folder structure refactor

* fix: issue with dependency path in electron

* chore: update version in demo db

* v2: refactor (#264)

* chore: upgrade dependencies

* chore: remove unused

* fix: issue with missing index data

* fix: discrepancy on counting events

* fix: swapped classes

* fix: prevent attempting load in empty list

* chore: cleanup completed

* chore: remove unused

* refactor: convert to typescript

* refactor: migrate to new endpoints

* refactor: convert to typescript

* fix: prevent issue with undefined process

* fix: small code smells

* fix: issue with missing version variable on build

* refactor: configure retries on data fetching

* style: design review

* fix: prevent cursor out of range

* lint: react query linting

* style: checkbox styles

* v2 event editor (#265)

* style: add cursor indicator in all blocks

* style: prevent reflows on width

* style: harmonise white range

* ux: focus elements on cursor nav

* ux: prevent focus on indicators

* refactor: convert to typescript

* fix: missing dependency

* chore: add linter rules for dependency arrays

* style: style editable component

* style: tweak input styles

* style: tweak switch styles

* style: style tweaks

* V2 sentry (#267)

* chore: add sentry to frontend

* chore: add sentry to server

* chore: report only in production

* chore: add sentry source maps

* v2 alpha 3 (#272)

* style: correct background colours

* chore: add folder resolutions to vite

* refactor: add auto refetch to HTTP APIs

* fix: view settings override endpoint

* refactor: memoize callbacks

* fix: reorder reaching wrong data adapter

* refactor: memoize callbacks

* style: presentation cleanup

* fix(delete): prevent flow with deleted event in playback

* refactor: convert to typescript

* refactor: small fixes and typescript conversion

* ux: improve feedback on local changes

* refactor: cleanup props

* v2 event timer (#274)

* lint: cleanup unused

* refactor: rename component to avoid conflict

* style: gap in element row

* refactor: rename playstate -> playback

* refactor: cleanup usages of timer and prepare integration manager

* style: re-arrange button order

* refactor: improve event loading

* feat(timer-service): hot reload

* fix: issue with duration input

* chore: cleanup debug

* refactor: resolve poll from runtime store

* refactor: cleanup merge

* refactor: small improvements in timer hot-reload

* v2: views (#278)

* config(sentry): instrumentation only in production

* chore: upgrade deps

* refactor: simplify and convert to ts

* refactor: composition and styles

* chore: remove deprecated deps

* ux: close menu on click outside

* style: small tweaks and clarifications

* refactor: retire PiP view

* ux: small tweaks

* style: cleanup deprecated styles

* chore: remove unused

* refactor: remove usages of deprecated fields and improve null timer presentation

* refactor: improve composition of countdown

* style: cleanup overridable styles

* ux: show event overtime status

* style: correct logic in paused timers

* v2: roll (#280)

* ux: delete flow

* style: small tweaks in interface

* refactor: gracefully quit on error

* fix: logic around updating events

* chore: cleanup dictionary

* refactor: improve DX on creating aux files

* fix: safe destructure function return

* refactor: safe handling of falsy timer values

* fix: improve ux on stopping roll mode

* chore: upgrade deps

* feat: roll mode

* refactor: remove unused

* V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Fix: #279 End Message in Minimal Timer (#286)

* fix: Minimal View #279 end message
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* hotfix: resolve data directory (#288)

* Feature/#276/qrcodes (#289)

* bugfix: wrong format when on 12h format

* update: website link in placeholder

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>

* V2 types (#291)

* V2 integration (#293)

* feat: integrations and lifecycles

* refactor: prevent issues with start order

* V2 fix offline (#294)

* fix: resolve path to UI build in dev

* fix: configure offline fetching

* V2 fix socket (#296)

* refactor: rename timer endpoint

* refactor: typescript

* refactor: rename eventData and viewSettings

* refactor: message manager uses event store

* style: alpha4 style tweaks (#299)

* fix: backgroundcolor for .blockNotes (#300)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Feat/countdown style (#290)

* feat: Countdown Style

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* fix: wrong parameter (#302)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 fix timer (#304)

* Feat/update check (#303)

* feat: show latest version in settings modal

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improve time input (#307)

* add the ability to parse time like 2h20m

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 ws store (#310)

* Update TimerService.ts

* refactor: message service publishes to store

* refactor: several type improvements

* V2 ws store wss (#309)

* refactor: shared logging types

* refactor: simplify message service consumption

* refactor: create discrete logging system

* refactor: move socket.io > websocket

* Feat/end action (#308)

* feat: end action

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* V2 osc (#313)

* chore: upgrade relevant deps

* fix: electron app to tray

* chore: cleanup dictionary

* feat: handle several messages in a event

* ux: disable irrelevant buttons in browser

* feat: parse and validate subscriptions

* feat: create UI for OSC Integration

* fix: cleanup logger behaviour

* feat: allow OSC settings to be changed at runtime

* v2 alpha5 (#315)

* fix: fetch in offline environments (#295)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* ux: remove duplicate showing of selected event

* several small fixes and UX improvements
---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* V2 docker (#312)

* Create docker-ci.yml

* docker build

* add build app

* path changes

* delete double workflow

* update docker run template

* cleanup

* resolve wierd path issue

* wrong path

* again wrong path

* rename docker releases

* move styles population and change to function call

* re-add old build pipeline

* rename buld file

* feractor init function

* refactor: startDb to initAssets

* refactor: startDB to initAssets

* fix: docker.cjs building whole app

* fix: docker environment

* update docker paths

* update: build command

* cleanup

* cleanup: commented code

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* v2 lazy rundown (#317)

* chore: update related deps

* refactor: memoise entrypoints

* refactor: store selectors

* refactor: improve component performance

* chore: remove unused dependencies

* refactor: improve component dnd

* V2 build (#319)

* chore: build pipeline for v2

* chore: run unit tests

* chore: configure sentry build

* Update test_v2.yml

* Update test_v2.yml

* V2 e2e (#320)

* chore: cleanup handling of a bad read

* chore: configure e2e testing

* chore: version bump

* V2 align (#321)

* fix: fetch in offline environments (#295)

* Add arm platforms to docker build (#297)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* Timer: fix too many renders error when using ?progress

* version bump (#314)

* style: small tweaks

* fix: prevent actions in roll mode

* chore: update external assets

* style: show shortcuts in quick blocks

* refactor: revert optimisations on callbacks

* fun: industry dictionary

* style: indicators for event features

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* refactor: ensure start order (#322)

* Update build_docker_v2.yml

* V2 fix roll (#323)

* chore: profile node

* fix: always roll

* Add translations for views (#325) (#328)

* Add translations for views (#325)

---------

Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>

* feat: notify ws clients on rundown changes (#327)

* V2 beta1 (#326)

* style: labels on added time

* style: remove mentions of PiP

* refactor: unify usage of ms for timers

* refactor: create events with 0 duration

* style: several small tweaks

* refactor: keep block when applying delays

* feat: blocks have titles

* style: improvements in time entry warnings

* style: override progress bar styles

* style: prevent overflow

* feat: show character count in editor

* refactor: provide initial payload

* refactor: lower test boundary

* refactor: get colour from swatches

* style: rename title block

* chore: version bump

* style: fix info scroll

* refactor: performance tweaks to event editor

* chore: upgrade deps

* refactor: prevent iterating on undefined

* style: small style tweaks

* fix: close editor on event delete

* refactor: improve performance on EventBlock

* style: small tweaks on blocks

* chore: upgrade deps

* chore: upgrade deps

* style: ensure events are always visible

* Fix/events enpoint (#329)

* fix: sentry hangs node process

* add onClick handler (#335)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix docker on different port (#337)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* V2 beta3 (#338)

* chore: upgrade local build documentation

* style: keep scrolling event in screen

* feat: delay is time entry

* refactor: remove unused

* refactor: remove unused

* refactor: batch store updates

* refactor: virtually remove cap on events

* refactor: style and behaviour tweaks to event block

* style: tweaks on schedules

* chore: remove sentry from server

* style: reorder menu

* chore: update docs

* fix: resolve to static port in dev mode

* V2 fix delay (#340)

* fix: avoid modifying original objects

* refactor: serve react as compressed br (#339)

* refactor: changes in time input

* V2 e2e playwright (#343)

* test: create utility for uploading test-db

* chore: upgrade deps

* refactor: remove unused functions

* chore: create testing boilerplate

* test: upload showfile

* refactor: parse time input (#351)

* chore: vite upgrade (#354)

* chore: upgrade vite

* feat: edit mode (#344)

* style: add mode selection to menu

* style: rename rundown functions

* feat: edit mode

* fix: osc modal (#352)

* refactor: parse time input

* fix: form dirty flag on dynamic elements

* style: cuesheet block (#355)

* style: cuesheet show block title

* style: cuesheet show full delay string

* v2 about (#362)

* chore: remove unused assets

* feat: about ontime

* refactor: cleanup unused

* v2 beta 4 (#361)

* ux: rename end action

* chore: update demo db

* style: tweaks on extracted rundown

* chore: prevent console logs in production code

* feat: go mode

* style: remove window size limits

* style: rename delete action

* style: small tweaks on icons

* fix: style override on params

* chore: update test db

* refactor: small code quality improvements

* refactor: prevent circular imports

* fix v2 roll timer (#363)

* fix: roll update skips elapsed time

* refactor: remove unused data

* style: fix style in roll state

* ux: content aware playback (#364)

* v2 beta 4 bump (#367)

* chore: update links to documentation

* chore: version bump

* Feature: quick start (#369)

* feat: quick start modal

* refactor: end message is part of view settings

* refactor: small tweaks and type improvements

* Feat/timer/stylechange (#341)

* feat: multi color progress bar

* add color support to minimal timer

Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

* v2 beta5 modals (#377)

* refactor: folder structure

* style: modal wrapper

* feat: add nordic translations

* v2 beta 5 (#375)

* chore: update documentation links

* fix: sentry has no access to error context

* style: clarify event history

* fix: issue with clipboard write in safari

* style: clarify event history

* refactor: improvements to follow logic in rundown

* refactor: improvements in go mode

* several style tweaks and small improvements

* Ux language (#378)

* ux: language from menu

* feat: add extra support for languages

german
portuguese
spanish

* refactor: type improvements

* v2 beta6 (#379)

* feat: operator key

* chore: update demo

* chore: version bump

* fix: prevent stale keys

* refactor: add new fields to validation

* style: tweaks to modal arrangement

* refactor: prevent parsing http

* ux: click anywhere in event to edit

* refactor: performance improvements to time input

* refactor: performance improvements menu

* V2 beta 6 (#381)

* fix: fetch in offline environments (#295)

* Add arm platforms to docker build (#297)

* add arm platforms to docker build

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* fix: docker build (#298)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* Timer: fix too many renders error when using ?progress (#305)

* Timer: fix too many renders error when using ?progress

* version bump (#314)

* chore: remove unused assets

* refactor: improve rundown loading time

* style: rename project file

* chore: update demo files

* refactor: improve loading time

* chore: upgrade upload packages

* refactor: modal redesign and ux improvements

* refactor: type and ux improvements on translations

* refactor: prevent flash on suspense

* refactor: remove unnecessary import

* chore: update tests

* refactor: cleanup imports

* fix: prevent flash on suspense

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>

* style views (#390)

* style: animate transition from left side

* refactor: improve schedule relationship

* refactor: prevent passing unhandled value

* style: remove inner radius in borders

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>
Co-authored-by: Kevin <60135953+kev-ac@users.noreply.github.com>
This commit is contained in:
Carlos Valente
2023-05-18 14:02:22 +02:00
committed by GitHub
parent 974a239b67
commit c8e979bc9a
698 changed files with 31752 additions and 34071 deletions
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
import style from './FeatureWrapper.module.scss';
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return (
<ProtectRoute permission='editor'>
<div className={style.wrapper}>{children}</div>
</ProtectRoute>
);
}
@@ -0,0 +1,8 @@
@use '../theme/v2Styles' as *;
.wrapper {
background: $bg-container-l1;
width: 100%;
height: 100%;
padding: max(16px, 2vh);
}
@@ -0,0 +1,17 @@
@use '../../../theme/v2Styles' as *;
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
@@ -0,0 +1,49 @@
import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss';
interface InputRowProps {
label: string;
placeholder: string;
text: string;
visible?: boolean;
actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
const handleInputChange = (newValue: string) => {
changeHandler(newValue);
};
return (
<div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}>
<Input
size='sm'
variant='ontime-filled'
value={text}
onChange={(event) => handleInputChange(event.target.value)}
placeholder={placeholder}
/>
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
@use '../../../theme/v2Styles' as *;
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.onAirSection {
margin-top: $section-spacing;
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
@@ -0,0 +1,53 @@
import { Button } from '@chakra-ui/react';
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import style from './MessageControl.module.scss';
export default function MessageControl() {
const data = useMessageControl();
return (
<div className={style.messageContainer}>
<InputRow
label='Timer screen message'
placeholder='Shown in stage timer'
text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<InputRow
label='Public / Backstage screen message'
placeholder='Shown in public and backstage screens'
text={data.publicMessage.text || ''}
visible={data.publicMessage.visible || false}
changeHandler={(newValue) => setMessage.publicText(newValue)}
actionHandler={() => setMessage.publicVisible(!data.publicMessage.visible)}
/>
<InputRow
label='Lower third message'
placeholder='Shown in lower third'
text={data.lowerMessage.text || ''}
visible={data.lowerMessage.visible || false}
changeHandler={(newValue) => setMessage.lowerText(newValue)}
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label>
<Button
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)}
data-testid='toggle on air'
>
{data?.onAir ? 'Ontime is On Air' : 'Ontime is Off Air'}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils';
import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => {
return (
<div className={style.messages} data-testid='panel-messages-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
<div className={style.content}>
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
</div>
</div>
);
};
export default memo(MessageControlExport);
@@ -0,0 +1,4 @@
.mainContainer {
width: 100%;
margin: 0 auto;
}
@@ -0,0 +1,23 @@
import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
import style from './PlaybackControl.module.scss';
export default function PlaybackControl() {
const data = usePlaybackControl();
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback} />
<PlaybackButtons
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
/>
</div>
);
}
@@ -0,0 +1,24 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils';
import PlaybackControl from './PlaybackControl';
import style from '../../editors/Editor.module.scss';
const TimerControlExport = () => {
return (
<div className={style.playback} data-testid='panel-timer-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timercontrol')} />
<div className={style.content}>
<ErrorBoundary>
<PlaybackControl />
</ErrorBoundary>
</div>
</div>
);
};
export default memo(TimerControlExport);
@@ -0,0 +1,43 @@
@use '../../../../theme/v2Styles' as *;
.buttonContainer {
padding-top: $element-spacing;
display: grid;
grid-template-areas:
"go playback"
"go transport"
"extra extra";
grid-template-rows: repeat(3, 32px);
gap: $element-spacing;
}
.go {
grid-area: go;
font-size: 5em;
}
@mixin spaced-flex {
display: flex;
justify-content: space-evenly;
gap: $element-spacing;
}
.playbackContainer {
grid-area: playback;
@include spaced-flex;
}
.transportContainer {
grid-area: transport;
@include spaced-flex;
}
.extra {
grid-area: extra;
@include spaced-flex;
}
.invertX {
transform: rotateY(180deg);
}
@@ -0,0 +1,106 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import style from './PlaybackButtons.module.scss';
interface PlaybackButtonsProps {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disablePrev = isRolling || noEvents || isFirst;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
if (isArmed) {
setPlayback.start();
} else {
setPlayback.startNext();
}
};
return (
<div className={style.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={style.go}>
{goModeText}
</TapButton>
<div className={style.playbackContainer}>
<TapButton
onClick={setPlayback.start}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
</TapButton>
<TapButton
onClick={setPlayback.pause}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
</TapButton>
</div>
<div className={style.transportContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.previous} disabled={disablePrev}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
</div>
<div className={style.extra}>
<TapButton
onClick={setPlayback.roll}
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 1fr 1fr 5em;
gap: $element-inner-spacing;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
grid-area: ind;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
background-color: $black-10;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
}
.indRollActive {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 4px;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
.start,
.finish,
.roll {
height: 24px;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -0,0 +1,111 @@
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimerDisplay from '../../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import style from './PlaybackTimer.module.scss';
interface PlaybackTimerProps {
playback: Playback;
}
export default function PlaybackTimer(props: PlaybackTimerProps) {
const { playback } = props;
const timer = useTimer();
// TODO: checkout typescript in utilities
const started = millisToString(timer.startedAt);
const finish = millisToString(timer.expectedFinish);
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
const disableButtons = isStopped || isRolling;
const isOvertime = timer.current !== null && timer.current < 0;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const resolveAddedTimeLabel = () => {
function resolveClosestUnit(ms: number) {
if (ms < 6000) {
return `${millisToSeconds(ms)} seconds`;
} else if (ms < 12000) {
return '1 minute';
} else {
return `${millisToMinutes(ms)} minutes`;
}
}
if (timer.addedTime > 0) {
return `Added ${resolveClosestUnit(timer.addedTime)}`;
}
if (timer.addedTime < 0) {
return `Removed ${resolveClosestUnit(timer.addedTime)}`;
}
return '';
};
const addedTimeLabel = resolveAddedTimeLabel();
return (
<div className={style.timeContainer}>
<div className={style.indicators}>
<Tooltip label={rollLabel}>
<div className={isRolling ? style.indRollActive : style.indRoll} />
</Tooltip>
<div className={isOvertime ? style.indNegativeActive : style.indNegative} />
<Tooltip label={addedTimeLabel}>
<div className={hasAddedTime ? style.indDelayActive : style.indDelay} />
</Tooltip>
</div>
<div className={style.timer}>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
</div>
{isWaiting ? (
<div className={style.roll}>
<span className={style.rolltag}>Roll: Countdown to start</span>
</div>
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.time}>{finish}</span>
</div>
</>
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
$button-bg-gray: $gray-1050;
$button-color-white: $gray-50;
@mixin tap-factory($theme-color) {
font-family: $ontime-font-family;
font-size: 18px;
border-radius: $component-border-radius-md;
width: 100%;
transition-property: color, background-color;
transition-duration: $transition-time-feedback;
display: grid;
place-content: center;
letter-spacing: 0.5px;
background-color: $button-bg-gray;
color: $theme-color;
&:disabled {
cursor: not-allowed;
opacity: $opacity-disabled;
}
&:hover:not(:disabled) {
color: $button-color-white;
background-color: $theme-color;
}
&:active:not(:disabled) {
color: $button-bg-gray;
background-color: $button-color-white;
transition-property: color, background-color;
transition-duration: $transition-time-action;
}
&.active {
background-color: $theme-color;
color: $button-color-white;
}
}
.tapButton.neutral {
@include tap-factory($gray-50);
&:hover:not(:disabled) {
color: $gray-50;
background-color: $gray-1000;
}
&:active:not(:disabled) {
color: $button-bg-gray;
background-color: $button-color-white;
transition-property: color, background-color;
transition-duration: $transition-time-action;
}
}
.tapButton.play {
@include tap-factory($playback-start);
}
.tapButton.roll {
@include tap-factory($ontime-roll);
}
.tapButton.pause {
@include tap-factory($ontime-paused);
}
.tapButton.ontime {
@include tap-factory($ontime-color);
}
.tapButton.stop {
@include tap-factory($ontime-stop);
}
.tapButton.normal {
aspect-ratio: 3/1;
}
.tapButton.square {
aspect-ratio: 1;
font-size: 14px;
}
.tapButton.fill {
aspect-ratio: unset;
height: 100%;
font-size: 1.5em;
}
@@ -0,0 +1,31 @@
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { Playback } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
aspect?: 'normal' | 'square' | 'fill';
square?: boolean;
free?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
active?: boolean;
className?: string;
}
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement>) => {
const { children, disabled, onClick, theme = 'neutral', aspect = 'normal', active, className } = props;
const classes = cx([style.tapButton, className, style[theme], style[aspect], active ? style.active : null]);
return (
<button className={classes} disabled={disabled} type='button' onClick={onClick} ref={ref}>
{children}
</button>
);
});
TapButton.displayName = 'TabButton';
export default TapButton;
@@ -0,0 +1,220 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
$menu-width: 48px;
$rundown-width: 46em;
$playback-width: 450px;
@mixin absolute-top-right($distance) {
position: absolute;
top: $distance;
right: $distance;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
.corner {
display: none;
@include absolute-top-right(8px);
transform: rotate(45deg);
}
.mainContainer {
background: $ui-black;
width: 100%;
height: 100%;
margin: auto;
color: $ui-white;
padding: 16px 8px;
font-family: $ontime-font-family;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width $rundown-width $playback-width auto;
grid-template-areas:
'sett rundown play info'
'sett rundown mess info';
gap: 8px;
.rundown,
.playback,
.messages,
.info,
.settings {
position: relative;
.corner {
display: inline;
}
}
}
/* 2/3 window, hide info */
@media (max-width: 1450px) and (min-height: 700px) {
.mainContainer {
height: 100%;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width $rundown-width auto;
.info {
visibility: hidden;
}
}
}
/* 1/2 window, event list only */
@media (max-width: 1100px) {
.mainContainer {
height: 100%;
grid-template-rows: 100%;
grid-template-columns: $menu-width $rundown-width;
grid-template-areas:
'sett even';
.info,
.messages,
.playback {
visibility: hidden;
}
}
}
/* 1/3 window, show control only */
@media (max-width: 850px) and (min-height: 500px) {
.mainContainer {
grid-template-rows: auto 1fr;
grid-template-columns: 100%;
grid-template-areas:
'play'
'mess';
.playback,
.messages {
visibility: visible;
}
.rundown,
.info,
.settings {
visibility: hidden;
}
}
}
/* 1/3 corner window, playback only */
@media (max-width: 850px) and (max-height: 500px) {
.mainContainer {
grid-template-rows: 100%;
grid-template-columns: 100%;
grid-template-areas: 'play';
.playback {
visibility: visible;
}
.rundown,
.messages,
.info,
.settings {
visibility: hidden;
}
}
}
.mainContainer {
.settings,
.rundown,
.messages,
.playback,
.info {
border-radius: 8px;
height: 100%;
background-color: $bg-container-l2;
padding: 0.8em 1.5em;
display: flex;
flex-direction: column;
}
}
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
width: 100vw;
left: 0;
z-index: 10;
color: white;
transition: bottom $transition-time-feedback;
&.noEvent {
bottom: -500px;
transition: bottom 0.7s;
}
.eventEditorLayout {
display: flex;
}
.header {
background-color: $gray-1200;
padding: 8px;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
}
}
.rundown {
grid-area: rundown;
height: 100%;
.content {
height: calc(100% - 24px);
overflow: hidden;
}
}
.info {
grid-area: info;
min-width: 17em;
max-width: 800px;
.content {
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.messages {
grid-area: mess;
}
.playback {
grid-area: play;
min-height: 250px;
max-height: 380px;
min-width: 450px;
}
.mainContainer > .settings {
grid-area: sett;
background-color: transparent;
margin: 0;
padding: 0 8px 0 0;
width: fit-content;
display: flex;
flex-direction: column;
}
.content {
padding-top: 24px;
}
@@ -0,0 +1,72 @@
import { lazy, useEffect } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import UploadModal from '../modals/upload-modal/UploadModal';
import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
const TimerControl = lazy(() => import('../../features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
const Info = lazy(() => import('../../features/info/InfoExport'));
const EventEditor = lazy(() => import('../../features/event-editor/EventEditorExport'));
const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal'));
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
export default function Editor() {
const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure();
const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure();
const {
isOpen: isIntegrationModalOpen,
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
// Set window title
useEffect(() => {
document.title = 'ontime - Editor';
}, []);
return (
<>
<ErrorBoundary>
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<div id='settings' className={styles.settings}>
<ErrorBoundary>
<MenuBar
onSettingsOpen={onSettingsOpen}
isSettingsOpen={isSettingsOpen}
onSettingsClose={onSettingsClose}
isUploadOpen={isUploadModalOpen}
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isAboutOpen={isAboutModalOpen}
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
/>
</ErrorBoundary>
</div>
<Rundown />
<MessageControl />
<TimerControl />
<Info />
</div>
<EventEditor />
</>
);
}
@@ -0,0 +1,11 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Editor from './Editor';
export default function ProtectedEditor() {
return (
<ProtectRoute permission='editor'>
<Editor />
</ProtectRoute>
);
}
@@ -0,0 +1,117 @@
@use '../../theme/v2Styles' as *;
.eventEditor {
padding: 16px 32px 32px 32px;
width: 100%;
gap: max(16px, 2vh);
display: grid;
grid-template-areas:
'eventInfo eventActions'
'timeOptions titles';
grid-template-columns: auto 1fr;
.timers,
.timeSettings {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.eventInfo {
grid-area: eventInfo;
.eventId {
margin-left: $element-inner-spacing;
user-select: text;
}
}
.eventActions {
grid-area: eventActions;
margin-left: auto;
}
.timeOptions {
grid-area: timeOptions;
display: flex;
gap: 24px;
}
.titles {
grid-area: titles;
display: grid;
grid-template-areas: 'left right';
grid-template-columns: 1fr 1fr;
.left,
.right {
display: flex;
flex-direction: column;
gap: 8px;
}
.left {
grid-area: left;
padding: 0 16px;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 16px;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
}
@mixin input-label() {
font-size: 13px;
color: $label-gray;
}
.countedInput {
display: flex;
justify-content: space-between;
@include input-label;
}
.inputLabel {
display: block;
@include input-label;
&.delayLabel {
color: $ontime-delay-text;
}
&.publicToggle {
height: 32px;
display: flex;
align-items: center;
justify-items: center;
gap: 8px;
}
}
.spacer {
height: 20px;
}
.inline {
display: flex;
align-items: center;
gap: 16px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
}
.padTop {
margin-top: 8px;
}
.fullHeight {
height: 100%
}
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useEffect(() => {
if (!data || !openId) {
return;
}
const eventIndex = data.findIndex((event) => event.id === openId);
if (eventIndex > -1) {
const event = data[eventIndex];
if (event.type === 'event') {
setDelay(getDelayTo(data, eventIndex));
setEvent(data[eventIndex] as OntimeEvent);
}
}
}, [data, event, openId]);
if (!event) {
return <span>Loading...</span>;
}
return (
<div className={style.eventEditor}>
<div className={style.eventInfo}>
Event ID
<span className={style.eventId}>{event.id}</span>
</div>
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={delay}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorTitles
eventId={event.id}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
note={event.note}
colour={event.colour}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import { memo } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './EventEditor';
import style from '../editors/Editor.module.scss';
/* Styling for action buttons */
const closeBtnStyle = {
size: 'md',
variant: 'ghost',
colorScheme: 'white',
_hover: { bg: '#ebedf0', color: '#333' },
};
const EventEditorExport = () => {
const editId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
const removeOpenEvent = () => setEditId(null);
return (
<div className={editorStyle}>
<ErrorBoundary>
<div className={style.eventEditorLayout}>
<EventEditor />
<div className={style.header}>
<IconButton aria-label='Close Menu' icon={<IoClose />} onClick={removeOpenEvent} {...closeBtnStyle} />
</div>
</div>
</ErrorBoundary>
</div>
);
};
export default memo(EventEditorExport);
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: TitleActions;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
const { field, label, initialValue, submitHandler } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
return (
<div className={`${style.column} ${style.fullHeight}`}>
<div className={style.countedInput}>
<label className={style.inputLabel}>{label}</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Textarea
size='sm'
resize='none'
variant='ontime-filled'
style={{ height: '100%' }}
data-testid='input-textarea'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps {
field: TitleActions;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
}
export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
submitOnEnter: true,
});
return (
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel}>{label}</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Input
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,149 @@
import { memo, useState } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride' | 'timerType' | 'endAction' | 'isPublic';
// Todo: add previous end to TimeInput fields
const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
const valid = validateEntry(entry, val, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
};
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value as number;
newEventData.timeEnd = timeStart + (value as number);
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value as number, timeEnd);
newEventData.timeStart = value as number;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(timeStart, value as number);
newEventData.timeEnd = value as number;
break;
}
case 'isPublic': {
updateEvent({ id: eventId, isPublic: !(value as boolean) });
break;
}
default: {
if (field === 'timerType' || field === 'endAction') {
// @ts-expect-error -- not sure how to typecheck here
newEventData[field as keyof OntimeEvent] = value as string;
} else {
return;
}
}
}
updateEvent(newEventData);
};
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const startLabel = delayTime ? `New start ${millisToString(timeStart + delay)}` : 'Start time';
const endLabel = delayTime ? `New end ${millisToString(timeEnd + delay)}` : 'End time';
const inputTimeLabels = cx([style.inputLabel, delayTime ? style.delayLabel : null]);
return (
<div className={style.timeOptions}>
<div className={style.timers}>
<label className={inputTimeLabels}>{startLabel}</label>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels}>{endLabel}</label>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel}>Duration</label>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
<label className={style.inputLabel}>Timer Type</label>
<Select
size='sm'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
<Select
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
<span className={style.spacer} />
<label className={`${style.inputLabel} ${style.publicToggle}`}>
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
Event is public
</label>
</div>
</div>
);
};
export default memo(EventEditorTimes);
@@ -0,0 +1,50 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
@@ -0,0 +1,19 @@
import { PropsWithChildren, useState } from 'react';
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
interface CollapsableInfoProps {
title: string;
}
export default function CollapsableInfo(props: PropsWithChildren<CollapsableInfoProps>) {
const { title, children } = props;
const [collapsed, setCollapsed] = useState(false);
return (
<>
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && children}
</>
);
}
@@ -0,0 +1,47 @@
@use '../../theme/mixins' as *;
@use '../../theme/v2Styles' as *;
.panelHeader {
font-size: $inner-section-text-size;
font-family: $ontime-font-family;
color: $label-gray;
display: flex;
justify-content: space-between;
}
.labels {
font-size: $inner-section-text-size;
display: flex;
flex-direction: column;
gap: $element-inner-spacing;
}
.label {
color: $section-white;
}
.content {
color: $secondary-text-gray;
margin-left: $element-inner-spacing;
font-size: $text-body-size;
}
.interfaceList {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
.interface {
@include action-link;
font-size: $inner-section-text-size;
white-space: nowrap;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useInfoPanel } from '../../common/hooks/useSocket';
import CollapsableInfo from './CollapsableInfo';
import InfoLogger from './InfoLogger';
import InfoNif from './InfoNif';
import InfoTitles from './InfoTitles';
import style from './Info.module.scss';
export default function Info() {
const data = useInfoPanel();
const titlesNow = {
title: data.titles.titleNow || '',
subtitle: data.titles.subtitleNow || '',
presenter: data.titles.presenterNow || '',
note: data.titles.noteNow || '',
};
const titlesNext = {
title: data.titles.titleNext || '',
subtitle: data.titles.subtitleNext || '',
presenter: data.titles.presenterNext || '',
note: data.titles.noteNext || '',
};
const selected = !data.numEvents
? 'No events'
: `Event ${data.selectedEventIndex !== null ? data.selectedEventIndex + 1 : '-'} / ${
data.numEvents ? data.numEvents : '-'
}`;
return (
<>
<div className={style.panelHeader}>
<span>Ontime running on port 4001</span>
<span>{selected}</span>
</div>
<CollapsableInfo title='Network Info'>
<InfoNif />
</CollapsableInfo>
<CollapsableInfo title='Playing Now'>
<InfoTitles data={titlesNow} />
</CollapsableInfo>
<CollapsableInfo title='Playing Next'>
<InfoTitles data={titlesNext} />
</CollapsableInfo>
<CollapsableInfo title='Log'>
<InfoLogger />
</CollapsableInfo>
</>
);
}
@@ -0,0 +1,24 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../common/utils/linkUtils';
import Info from './Info';
import style from '../editors/Editor.module.scss';
const InfoExport = () => {
return (
<div className={style.info} data-testid='panel-info'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'info')} />
<div className={style.content}>
<ErrorBoundary>
<Info />
</ErrorBoundary>
</div>
</div>
);
};
export default memo(InfoExport);
@@ -0,0 +1,54 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/mixins' as *;
$info-gray: $secondary-text-gray;
$info-hover: $section-white;
.log {
flex: 1;
height: 100%;
overflow-y: scroll;
font-size: 0.8em;
user-select: text;
}
.logEntry {
display: flex;
margin-bottom: 2px;
&.INFO {
color: $info-gray;
}
&.WARN {
color: $warning-orange;
}
&.ERROR {
color: $error-red;
}
&:hover {
color: $info-hover;
}
.time {
width: 4.5em
}
.origin {
width: 6em;
}
.msg {
flex: 1;
}
}
.buttonBar {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
margin-bottom: 8px;
}
@@ -0,0 +1,130 @@
import { useCallback, useState } from 'react';
import { Button } from '@chakra-ui/react';
import { clearLogs, useLogData } from '../../common/stores/logger';
import style from './InfoLogger.module.scss';
enum LogFilter {
User = 'USER',
Client = 'CLIENT',
Server = 'SERVER',
RX = 'RX',
TX = 'TX',
Playback = 'PLAYBACK',
}
export default function InfoLogger() {
const { logs: logData } = useLogData();
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
const matchers: LogFilter[] = [];
if (showUser) {
matchers.push(LogFilter.User);
}
if (showClient) {
matchers.push(LogFilter.Client);
}
if (showServer) {
matchers.push(LogFilter.Server);
}
if (showRx) {
matchers.push(LogFilter.RX);
}
if (showTx) {
matchers.push(LogFilter.TX);
}
if (showPlayback) {
matchers.push(LogFilter.Playback);
}
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const disableOthers = useCallback((toEnable: LogFilter) => {
toEnable === LogFilter.User ? setShowUser(true) : setShowUser(false);
toEnable === LogFilter.Client ? setShowClient(true) : setShowClient(false);
toEnable === LogFilter.Server ? setShowServer(true) : setShowServer(false);
toEnable === LogFilter.RX ? setShowRx(true) : setShowRx(false);
toEnable === LogFilter.TX ? setShowTx(true) : setShowTx(false);
toEnable === LogFilter.Playback ? setShowPlayback(true) : setShowPlayback(false);
}, []);
return (
<>
<div className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.User}
</Button>
<Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Client}
</Button>
<Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Server}
</Button>
<Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.Playback}
</Button>
<Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.RX)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.RX}
</Button>
<Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.TX)}
onContextMenu={(e) => e.preventDefault()}
>
{LogFilter.TX}
</Button>
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}>
Clear
</Button>
</div>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
<span className={style.time}>{logEntry.time}</span>
<span className={style.origin}>{logEntry.origin}</span>
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
</ul>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import useInfo from '../../common/hooks-query/useInfo';
import { openLink } from '../../common/utils/linkUtils';
import style from './Info.module.scss';
export default function InfoNif() {
const { data } = useInfo();
const handleClick = (address: string) => {
const baseURL = 'http://__IP__:4001';
openLink(baseURL.replace('__IP__', address));
};
return (
<div className={style.interfaceList}>
{data?.networkInterfaces?.map((nif) => (
<span key={nif.address} onClick={() => handleClick(nif.address)} className={style.interface}>
{`${nif.name} - ${nif.address}`}
<IoArrowUp className={style.linkIcon} />
</span>
))}
</div>
);
}
@@ -0,0 +1,36 @@
import style from './Info.module.scss';
type TitleShape = {
title: string;
presenter: string;
subtitle: string;
note: string;
};
interface InfoTitleProps {
data: TitleShape;
}
export default function InfoTitles(props: InfoTitleProps) {
const { data } = props;
return (
<div className={style.labels}>
<div>
<span className={style.label}>Title:</span>
<span className={style.content}>{data.title}</span>
</div>
<div>
<span className={style.label}>Presenter:</span>
<span className={style.content}>{data.presenter}</span>
</div>
<div>
<span className={style.label}>Subtitle:</span>
<span className={style.content}>{data.subtitle}</span>
</div>
<div>
<span className={style.label}>Note:</span>
<span className={style.content}>{data.note}</span>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
@use '../../theme/ontimeColours' as *;
.gap {
height: 1em;
}
.open {
background: $blue-700;
}
+180
View File
@@ -0,0 +1,180 @@
import { memo, useCallback, useEffect } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoHelp } from '@react-icons/all-files/io5/IoHelp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import style from './MenuBar.module.scss';
interface MenuBarProps {
isSettingsOpen: boolean;
onSettingsOpen: () => void;
onSettingsClose: () => void;
isUploadOpen: boolean;
onUploadOpen: () => void;
isIntegrationOpen: boolean;
onIntegrationOpen: () => void;
isAboutOpen: boolean;
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
}
const buttonStyle = {
fontSize: '1.25em',
size: 'lg',
colorScheme: 'white',
_hover: {
background: 'rgba(255, 255, 255, 0.10)', // $white-10
},
_active: {
background: 'rgba(255, 255, 255, 0.13)', // $white-13
},
};
const MenuBar = (props: MenuBarProps) => {
const {
isSettingsOpen,
onSettingsOpen,
onSettingsClose,
isUploadOpen,
onUploadOpen,
isIntegrationOpen,
onIntegrationOpen,
isAboutOpen,
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
} = props;
const { isElectron, sendToElectron } = useElectronEvent();
const appMode = useAppMode((state) => state.mode);
const setAppMode = useAppMode((state) => state.setMode);
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
const sendShutdown = () => {
if (isElectron) {
sendToElectron('shutdown', 'now');
}
};
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
// open if not open
isSettingsOpen ? onSettingsClose() : onSettingsOpen();
}
}
},
[isSettingsOpen, onSettingsClose, onSettingsOpen],
);
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
return (
<VStack>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoColorWand />}
className={isQuickStartOpen ? style.open : ''}
clickHandler={onQuickStartOpen}
tooltip='Quick start'
aria-label='Quick start'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoPushOutline />}
className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen}
tooltip='Import project file'
aria-label='Import project file'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSaveOutline />}
clickHandler={downloadRundown}
tooltip='Export project file'
aria-label='Export project file'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoPlay />}
className={appMode === AppMode.Run ? style.open : ''}
clickHandler={setRunMode}
tooltip='Run mode'
aria-label='Run mode'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoOptions />}
className={appMode === AppMode.Edit ? style.open : ''}
clickHandler={setEditMode}
tooltip='Edit mode'
aria-label='Edit mode'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen}
tooltip='Integrations'
aria-label='Integrations'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSettingsOutline />}
className={isSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen}
tooltip='Settings'
aria-label='Settings'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
className={isAboutOpen ? style.open : ''}
icon={<IoHelp />}
clickHandler={onAboutOpen}
tooltip='About'
aria-label='About'
/>
</VStack>
);
};
export default memo(MenuBar);
@@ -0,0 +1,4 @@
.headerButtons {
text-align: right;
padding-top: 24px;
}
@@ -0,0 +1,64 @@
import { memo, useCallback } from 'react';
import { Button, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useAppMode } from '../../common/stores/appModeStore';
import style from './RundownMenu.module.scss';
const RundownMenu = () => {
const setEditId = useAppMode((state) => state.setEditId);
const setCursor = useAppMode((state) => state.setCursor);
const { addEvent, deleteAllEvents } = useEventAction();
const newEvent = useCallback(() => {
addEvent({ type: SupportedEvent.Event });
}, [addEvent]);
const newBlock = useCallback(() => {
addEvent({ type: SupportedEvent.Block });
}, [addEvent]);
const newDelay = useCallback(() => {
addEvent({ type: SupportedEvent.Delay });
}, [addEvent]);
const deleteAll = useCallback(() => {
deleteAllEvents();
setEditId(null);
setCursor(null);
}, [deleteAllEvents, setCursor, setEditId]);
return (
<div className={style.headerButtons}>
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton as={Button} leftIcon={<IoAdd />} size='sm' variant='ontime-subtle'>
Event...
</MenuButton>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={newEvent}>
Add event at start
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={newDelay}>
Add delay at start
</MenuItem>
<MenuItem icon={<FiMinusCircle />} onClick={newBlock}>
Add block at start
</MenuItem>
<MenuDivider />
<MenuItem icon={<FiTrash2 />} onClick={deleteAll} color='#D20300'>
Delete all events
</MenuItem>
</MenuList>
</Menu>
</div>
);
};
export default memo(RundownMenu);
@@ -0,0 +1,165 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
$el-padding-with-compensation: 24px; // 16 + 8
@mixin modal-link {
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
.headerNotes {
font-size: $text-body-size;
width: 100%;
padding: 0 $el-padding-with-compensation;
color: $modal-note-color;
margin-bottom: $section-spacing;
a {
display: block;
@include modal-link
}
}
.footerNotes {
font-size: $inner-section-text-size;
padding: 0 $el-padding-with-compensation;
color: $modal-note-color;
}
.divider {
margin: $element-spacing 0;
border: 0;
border-top: 1px solid $gray-100;
}
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
text-transform: uppercase;
}
.sectionContainer {
padding: 8px 16px;
display: flex;
flex-direction: column;
height: 100%
}
@mixin sectionSpacing {
border-radius: $component-border-radius-md;
padding: $element-spacing;
display: flex;
align-items: center;
&:hover {
background-color: $gray-50;
}
}
.columnSection {
@include sectionSpacing;
flex-direction: column;
align-items: flex-start;
gap: $element-inner-spacing;
}
.splitSection {
@include sectionSpacing;
gap: $section-spacing;
justify-content: space-between;
}
.entryRow {
@include sectionSpacing;
gap: $element-inner-spacing;
}
.sectionTitle {
font-size: $inner-section-text-size;
display: block;
width: 100%;
&.main {
font-weight: 600;
}
}
@mixin subsection {
font-size: $inner-section-text-size;
display: block;
}
.sectionSubtitle {
@include subsection;
color: $modal-note-color;
}
.error {
@include subsection;
color: $error-red;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
gap: $section-spacing;
}
.spacer {
flex-grow: 1;
}
.shiftRight {
align-self: flex-end;
}
.showPointer {
cursor: pointer;
}
.column {
display: flex;
flex-direction: column;
}
.twoColumn {
display: grid;
grid-template-columns: auto 1fr;
}
.padBottom {
padding-bottom: $element-spacing;
}
.logo {
width: 48px;
height: 48px;
display: inline-block;
vertical-align: text-bottom;
margin-right: $section-spacing;
}
.updateSection {
padding-top: $el-padding-with-compensation;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: $element-inner-spacing;
.error {
font-size: $error-red;
}
}
.overflowContainer {
overflow-y: auto;
max-height: 40%;
}
@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { FormControl } from '@chakra-ui/react';
import style from './settings-modal/SettingsModal.module.scss';
interface ModalInputProps {
field: string;
title: string;
description: string;
error?: string;
}
export default function ModalInput(props: PropsWithChildren<ModalInputProps>) {
const { field, title, description, error, children } = props;
return (
<FormControl isInvalid={!!error} className={style.columnSection}>
<label htmlFor={field}>
<span className={style.sectionTitle}>{title}</span>
{error ? (
<span className={style.error}>{error}</span>
) : (
<span className={style.sectionSubtitle}>{description}</span>
)}
</label>
{children}
</FormControl>
);
}
@@ -0,0 +1,20 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.link {
display: flex;
align-items: center;
gap: 4px;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&.inline {
display: inline-flex;
}
&:hover {
color: $ontime-color;
}
}
@@ -0,0 +1,29 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import style from './ModalLink.module.scss';
interface ModalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ModalLink(props: ModalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { FormControl } from '@chakra-ui/react';
import style from './settings-modal/SettingsModal.module.scss';
interface ModalSplitInputProps {
field: string;
title: string;
description: string;
error?: string;
}
export default function ModalSplitInput(props: PropsWithChildren<ModalSplitInputProps>) {
const { field, title, description, error, children } = props;
return (
<FormControl isInvalid={!!error} className={style.splitSection}>
<label htmlFor={field}>
<span className={style.sectionTitle}>{title}</span>
{error ? (
<span className={style.error}>{error}</span>
) : (
<span className={style.sectionSubtitle}>{description}</span>
)}
</label>
{children}
</FormControl>
);
}
@@ -0,0 +1,33 @@
import { PropsWithChildren } from 'react';
import { Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
interface ModalWrapperProps {
isOpen: boolean;
onClose: () => void;
title: string;
size?: string;
}
export default function ModalWrapper(props: PropsWithChildren<ModalWrapperProps>) {
const { isOpen, onClose, title, size = 'xl', children } = props;
return (
<Modal
onClose={onClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size={size}
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>{title}</ModalHeader>
<ModalCloseButton />
{children}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,37 @@
import { Button, ModalFooter } from '@chakra-ui/react';
import styles from './Modal.module.scss';
interface OntimeModalFooterProps {
formId: string;
handleRevert: () => void;
isDirty: boolean;
isValid: boolean;
isSubmitting: boolean;
}
export default function OntimeModalFooter(props: OntimeModalFooterProps) {
const { formId, handleRevert, isDirty, isValid, isSubmitting } = props;
const disableRevert = !isDirty;
const disableSubmit = isSubmitting || !isDirty || !isValid;
return (
<ModalFooter className={styles.buttonSection}>
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
Revert to saved
</Button>
<Button
type='submit'
form={formId}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Save
</Button>
</ModalFooter>
);
}
@@ -0,0 +1,68 @@
import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import { ReactComponent as OntimeLogo } from '@/assets/images/ontime-logo.svg';
import { version } from '../../../../package.json';
import { gitbookUrl, githubUrl } from '../../../externals';
import ModalLink from '../ModalLink';
import UpdateChecker from './UpdateChecker';
import styles from '../Modal.module.scss';
interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AboutModal(props: AboutModalProps) {
const { isOpen, onClose } = props;
return (
<Modal
size='sm'
isOpen={isOpen}
onClose={onClose}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>
About Ontime
<ModalCloseButton />
</ModalHeader>
<ModalBody className={styles.body}>
<div className={styles.twoColumn}>
<OntimeLogo className={styles.logo} />
<div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Ontime</span>
Free Open Source Software for managing rundowns and event timers
<ModalLink href='https://www.getontime.no'>www.getontime.no</ModalLink>
</div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Current version</span>
{`You are currently using Ontime ${version}`}
</div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Docs</span>
<ModalLink href={gitbookUrl}>Read the docs over at GitBook</ModalLink>
</div>
<div>
<span className={styles.sectionTitle}>Github</span>
<ModalLink href={githubUrl}>Follow the project on GitHub</ModalLink>
</div>
<UpdateChecker version={version} />
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi';
import ModalLink from '../ModalLink';
import styles from '../Modal.module.scss';
interface UpdateCheckerProps {
version: string;
}
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
export default function UpdateChecker(props: UpdateCheckerProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<div className={styles.updateSection}>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</div>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={styles.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>;
}
return null;
}
@@ -0,0 +1,46 @@
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import ModalWrapper from '../ModalWrapper';
import OscIntegration from './OscIntegration';
import OscSettings from './OscSettings';
import styles from '../Modal.module.scss';
interface IntegrationModalProps {
isOpen: boolean;
onClose: () => void;
}
const oscDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/osc';
export default function IntegrationModal(props: IntegrationModalProps) {
const { isOpen, onClose } = props;
return (
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
<ModalBody>
<div className={styles.headerNotes}>
Manage settings related to protocol integrations
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
Read the docs
</a>
</div>
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>OSC</Tab>
<Tab>OSC Integration</Tab>
</TabList>
<TabPanels>
<TabPanel>
<OscSettings />
</TabPanel>
<TabPanel>
<OscIntegration />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
</ModalWrapper>
);
}
@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { OSCSettings, OscSubscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import { generateId } from 'ontime-utils';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import OntimeModalFooter from '../OntimeModalFooter';
import OscSubscriptionRow from './OscSubscriptionRow';
import styles from '../Modal.module.scss';
type OntimeCycle = keyof typeof TimerLifeCycle;
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when timers are updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
export default function OscIntegration() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid },
} = useForm<PlaceholderSettings>({
defaultValues: data,
values: data,
});
const [subscriptionState, setSubscription] = useState<OscSubscription>(
data?.subscriptions || oscPlaceholderSettings.subscriptions,
);
const [hasManualChange, setHasManualChange] = useState(false);
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
const resetForm = () => {
const originalData = data || oscPlaceholderSettings;
setSubscription(originalData.subscriptions);
// @ts-expect-error -- we know the data here is safe
reset(originalData);
};
const deleteSubscriptionEntry = (cycle: OntimeCycle, id: string) => {
setSubscription((prev) => {
const newData = { ...prev };
newData[cycle] = [...prev[cycle].filter((el) => el.id !== id)];
return newData;
});
setHasManualChange(true);
};
const addNewSubscriptionEntry = async (cycle: OntimeCycle) => {
setSubscription((prev) => {
const newData = structuredClone(prev);
newData[cycle] = [...prev[cycle], { id: generateId(), message: '', enabled: false }];
return newData;
});
setHasManualChange(true);
};
const onSubmit = async (values: OSCSettings | PlaceholderSettings) => {
try {
// @ts-expect-error -- we know of the type mismatch, not pertinent here
await mutateAsync(values);
setHasManualChange(false);
} catch (error) {
emitError(`Error setting OSC: ${error}`);
}
};
const subscriptionKeys = Object.keys(subscriptionState);
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSubscriptions'>
{subscriptionKeys.map((cycle, idx) => {
return (
<div key={`${cycle}-${idx}`}>
<OscSubscriptionRow
key={cycle}
cycle={cycle as TimerLifeCycle}
title={sectionText[cycle as TimerLifeCycle].title}
subtitle={sectionText[cycle as TimerLifeCycle].subtitle}
visible={showSection === cycle}
setShowSection={setShowSection}
subscriptionOptions={subscriptionState[cycle as TimerLifeCycle]}
handleDelete={deleteSubscriptionEntry}
handleAddNew={addNewSubscriptionEntry}
register={register}
/>
{idx < subscriptionKeys.length - 1 && <hr className={styles.divider} />}
</div>
);
})}
<OntimeModalFooter
formId='oscSubscriptions'
handleRevert={resetForm}
isDirty={isDirty || hasManualChange}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,166 @@
import { useForm } from 'react-hook-form';
import { FormControl, Input, Switch } from '@chakra-ui/react';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
import OntimeModalFooter from '../OntimeModalFooter';
import styles from '../Modal.module.scss';
export default function OscSettings() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
reset,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<PlaceholderSettings>({
defaultValues: data,
values: data,
});
const onSubmit = async (values: PlaceholderSettings) => {
const numericPortIn = Number(values.portIn);
const numericPortOut = Number(values.portOut);
if (numericPortIn === numericPortOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = {
...values,
portIn: numericPortIn,
portOut: numericPortOut,
};
try {
await mutateAsync(parsedValues);
} catch (error) {
emitError(`Error setting OSC: ${error}`);
}
};
const resetForm = () => {
// @ts-expect-error -- we know the types dont match
reset(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>OSC Input</span>
<span className={styles.sectionSubtitle}>Control Ontime with OSC</span>
</div>
<Switch {...register('enabledIn')} variant='ontime-on-light' />
</div>
<FormControl isInvalid={!!errors.portIn} className={styles.splitSection}>
<label htmlFor='portIn'>
<span className={styles.sectionTitle}>Listen on Port</span>
{errors.portIn ? (
<span className={styles.error}>{errors.portIn.message}</span>
) : (
<span className={styles.sectionSubtitle}>Default 8888</span>
)}
</label>
<Input
id='portIn'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled-on-light'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</FormControl>
<div style={{ height: '16px' }} />
<div className={styles.splitSection}>
<div>
<span className={styles.sectionTitle} style={{ fontWeight: 600 }}>
OSC Output
</span>
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
</div>
<Switch {...register('enabledOut')} variant='ontime-on-light' />
</div>
<FormControl isInvalid={!!errors.targetIP} className={styles.splitSection}>
<label htmlFor='targetIP'>
<span className={styles.sectionTitle}>OSC target IP</span>
{errors.targetIP ? (
<span className={styles.error}>{errors.targetIP.message}</span>
) : (
<span className={styles.sectionSubtitle}>Default 127.0.0.1</span>
)}
</label>
<Input
id='targetIP'
placeholder='127.0.0.1'
width='140px'
size='sm'
textAlign='right'
variant='ontime-filled-on-light'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</FormControl>
<FormControl className={styles.splitSection}>
<label htmlFor='portOut'>
<span className={styles.sectionTitle}>OSC target Port</span>
{errors.portOut ? (
<span className={styles.error}>{errors.portOut.message}</span>
) : (
<span className={styles.sectionSubtitle}>Default 9999</span>
)}
</label>
<Input
id='portOut'
placeholder='9999'
width='75px'
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled-on-light'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</FormControl>
<OntimeModalFooter
formId='oscSettings'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,75 @@
import { UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { OscSubscriptionOptions, TimerLifeCycle } from 'ontime-types';
import collapseStyles from '../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../Modal.module.scss';
interface OscSubscriptionRowProps {
cycle: TimerLifeCycle;
title: string;
subtitle: string;
visible: boolean;
setShowSection: (cycle: TimerLifeCycle) => void;
subscriptionOptions: OscSubscriptionOptions[];
handleDelete: (cycle: TimerLifeCycle, id: string) => void;
handleAddNew: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<any>;
}
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, subscriptionOptions, handleDelete, handleAddNew, register } =
props;
const hasTooManyOptions = subscriptionOptions.length >= 3;
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
const registerPrefix = `subscriptions.${cycle}`;
return (
<>
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>{title}</span>
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
</div>
<FiChevronUp className={visible ? collapseStyles.moreCollapsed : collapseStyles.moreExpanded} />
</div>
{visible && (
<>
{subscriptionOptions.map((option, idx) => (
<div key={option.id} className={styles.entryRow}>
<input type='hidden' {...register(`${registerPrefix}[${idx}].id`)} value={option.id} />
<IconButton
icon={<IoRemove />}
onClick={() => handleDelete(cycle, option.id)}
aria-label='delete'
size='xs'
colorScheme='red'
/>
<Input
placeholder='OSC Message'
size='xs'
variant='ontime-filled-on-light'
{...register(`${registerPrefix}[${idx}].message`)}
/>
<Switch variant='ontime-on-light' {...register(`${registerPrefix}[${idx}].enabled`)} />
</div>
))}
<Button
onClick={() => handleAddNew(cycle)}
className={styles.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
</>
)}
</>
);
}
@@ -0,0 +1,11 @@
export const inputProps = {
size: 'sm',
autoComplete: 'off',
};
export const portInputProps = {
...inputProps,
type: 'number',
min: '1024',
max: '65535',
};
@@ -0,0 +1,150 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { EventData } from 'ontime-types';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
interface QuickStartProps {
onClose: () => void;
isOpen: boolean;
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useEventData();
const {
handleSubmit,
register,
reset,
formState: { isSubmitting },
} = useForm({ defaultValues: data });
useEffect(() => {
reset(data);
}, [reset, data]);
const onSubmit = async (data: Partial<EventData>) => {
await postNew(data);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
};
const onReset = () => reset(eventDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
<Modal
onClose={onClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Ontime quick start</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.pad}>
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer}>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event title
<Input
variant='ontime-filled-on-light'
size='sm'
maxLength={50}
placeholder='Eurovision song contest'
{...register('title')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public Info
<Textarea
variant='ontime-filled-on-light'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
{...register('publicInfo')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR Code Url
<Input
variant='ontime-filled-on-light'
size='sm'
placeholder='www.getontime.no'
{...register('publicUrl')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage Info
<Textarea
variant='ontime-filled-on-light'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
{...register('backstageInfo')}
/>
</label>
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR Code Url
<Input
variant='ontime-filled-on-light'
size='sm'
placeholder='www.ontime.gitbook.io'
{...register('backstageUrl')}
/>
</label>
</div>
<div className={styles.footerNotes}>
Note: Application options will be kept but rundown and event data will be reset <br />
</div>
<ModalFooter className={styles.buttonSection}>
<Button onClick={onReset} isDisabled={disableButtons} variant='ontime-ghost-on-light' size='sm'>
Clear data
</Button>
<Button
type='submit'
isLoading={isSubmitting}
isDisabled={disableButtons}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
New project file
</Button>
</ModalFooter>
</form>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,152 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Alias } from 'ontime-types';
import { postAliases } from '../../../common/api/ontimeApi';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import useAliases from '../../../common/hooks-query/useAliases';
import { useEmitLog } from '../../../common/stores/logger';
import { handleLinks } from '../../../common/utils/linkUtils';
import { inputProps } from '../modalHelper';
import ModalLink from '../ModalLink';
import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases';
// we wrap the array in an object to be simplify react-hook-form
type Aliases = { aliases: Alias[] };
export default function AliasesForm() {
const { data, status, refetch } = useAliases();
const { emitError } = useEmitLog();
const {
control,
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid },
} = useForm<Aliases>({
defaultValues: { aliases: data },
values: { aliases: data || [] },
});
const { fields, append, remove } = useFieldArray({
name: 'aliases',
control,
});
const onSubmit = async (formData: Aliases) => {
try {
await postAliases(formData.aliases);
} catch (error) {
emitError(`Error saving aliases: ${error}`);
} finally {
await refetch();
}
};
const onReset = () => {
reset({ aliases: data });
};
const addNew = () => {
if (fields.length > 20) {
emitError('Maximum amount of aliases reacted (20)');
return;
}
append({
enabled: false,
alias: '',
pathAndParams: '',
});
};
const disableInputs = status === 'loading';
const hasTooManyOptions = fields.length >= 20;
return (
<form onSubmit={handleSubmit(onSubmit)} id='aliases' className={style.sectionContainer}>
<div style={{ height: '16px' }} />
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div className={style.column}>
<AlertTitle>URL Aliases</AlertTitle>
<AlertDescription>
Custom aliases allow providing a short name for any ontime URL. <br />
It serves two primary purposes: <br />
- Providing dynamic URLs for automation or unattended screens <br />
- Simplifying complex URLs
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
</AlertDescription>
</div>
</Alert>
<div style={{ height: '16px' }} />
<ul className={style.aliases}>
{fields.map((alias, index) => {
return (
<li className={style.aliasRow} key={alias.id}>
<IconButton
onClick={() => remove(index)}
aria-label='delete'
size='xs'
icon={<IoRemove />}
colorScheme='red'
isDisabled={disableInputs}
/>
<Input
{...inputProps}
{...register(`aliases.${index}.alias`)}
width='12em'
size='xs'
variant='ontime-filled-on-light'
placeholder='URL Alias'
isDisabled={disableInputs}
/>
<Input
{...inputProps}
{...register(`aliases.${index}.pathAndParams`)}
className={style.grow}
size='xs'
variant='ontime-filled-on-light'
placeholder='URL (portion after ontime Port)'
isDisabled={disableInputs}
/>
<TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.pathAndParams)}
tooltip='Test alias'
aria-label='Test alias'
size='xs'
variant='ontime-ghost-on-light'
icon={<IoOpenOutline />}
colorScheme='red'
isDisabled={disableInputs}
/>
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
</li>
);
})}
</ul>
<Button
onClick={addNew}
className={style.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
<OntimeModalFooter
formId='aliases'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,123 @@
import { useForm } from 'react-hook-form';
import { Input, Select } from '@chakra-ui/react';
import type { Settings } from 'ontime-types';
import { postSettings } from '../../../common/api/ontimeApi';
import useSettings from '../../../common/hooks-query/useSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isOnlyNumbers } from '../../../common/utils/regex';
import ModalSplitInput from '../ModalSplitInput';
import OntimeModalFooter from '../OntimeModalFooter';
import ModalPinInput from './ModalPinInput';
import style from './SettingsModal.module.scss';
export default function AppSettingsModal() {
const { data, status, refetch } = useSettings();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<Settings>({
defaultValues: data,
values: data,
});
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
emitError(`Error saving settings: ${error}`);
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const disableInputs = status === 'loading';
return (
<form onSubmit={handleSubmit(onSubmit)} id='app-settings' className={style.sectionContainer}>
<ModalSplitInput
field='serverPort'
title='Ontime is available on port'
description='Default 4001'
error={errors.serverPort?.message}
>
<Input
width='75px'
size='sm'
textAlign='right'
maxLength={5}
disabled
variant='ontime-filled-on-light'
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</ModalSplitInput>
<ModalSplitInput
field='editorKey'
title='Editor pin code'
description='Protect the editor with a pin code'
error={errors.editorKey?.message}
>
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<ModalSplitInput
field='operatorKey'
title='Operator pin code'
description='Protect the cuesheet with a pin code'
error={errors.operatorKey?.message}
>
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<div style={{ height: '16px' }} />
<ModalSplitInput
field='timeFormat'
title='Time Format'
description='Views 12 / 24 hours'
error={errors.timeFormat?.message}
>
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('timeFormat')}>
<option value='12'>12 hours eg. 11:00:10 PM</option>
<option value='24'>24 hours eg. 23:00:10</option>
</Select>
</ModalSplitInput>
<ModalSplitInput
field='language'
title='Views Language'
description='Language for static fields in views'
error={errors.language?.message}
>
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
<option value='en'>English</option>
<option value='de'>German</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
</Select>
</ModalSplitInput>
<OntimeModalFooter
formId='app-settings'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,169 @@
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input } from '@chakra-ui/react';
import { UserFields } from 'ontime-types';
import { postUserFields } from '../../../common/api/ontimeApi';
import useUserFields from '../../../common/hooks-query/useUserFields';
import { useEmitLog } from '../../../common/stores/logger';
import { inputProps } from '../modalHelper';
import ModalLink from '../ModalLink';
import ModalSplitInput from '../ModalSplitInput';
import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
export default function CuesheetSettings() {
const { data, status, refetch } = useUserFields();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<UserFields>({
defaultValues: data,
values: data,
});
const onSubmit = async (formData: UserFields) => {
try {
await postUserFields(formData);
} catch (error) {
emitError(`Error saving cuesheet settings: ${error}`);
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const disableInputs = status === 'loading';
return (
<form onSubmit={handleSubmit(onSubmit)} id='cuesheet-settings' className={style.sectionContainer}>
<div style={{ height: '16px' }} />
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div className={style.column}>
<AlertTitle>User Fields</AlertTitle>
<AlertDescription>
Allow for custom naming of additional data fields on each event (eg. light, sound, camera). <br />
<ModalLink href={userFieldsDocsUrl}>See the docs</ModalLink>
</AlertDescription>
</div>
</Alert>
<div style={{ height: '16px' }} />
<ModalSplitInput field='user0' title='User0' description='' error={errors.user0?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user0')}
/>
</ModalSplitInput>
<ModalSplitInput field='user1' title='User1' description='' error={errors.user1?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user1')}
/>
</ModalSplitInput>
<ModalSplitInput field='user2' title='User2' description='' error={errors.user2?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user2')}
/>
</ModalSplitInput>
<ModalSplitInput field='user3' title='User3' description='' error={errors.user3?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user3')}
/>
</ModalSplitInput>
<ModalSplitInput field='user4' title='User4' description='' error={errors.user4?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user4')}
/>
</ModalSplitInput>
<ModalSplitInput field='user5' title='User5' description='' error={errors.user5?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user5')}
/>
</ModalSplitInput>
<ModalSplitInput field='user6' title='User6' description='' error={errors.user6?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user6')}
/>
</ModalSplitInput>
<ModalSplitInput field='user7' title='User7' description='' error={errors.user7?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user7')}
/>
</ModalSplitInput>
<ModalSplitInput field='user8' title='User8' description='' error={errors.user8?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user8')}
/>
</ModalSplitInput>
<ModalSplitInput field='user9' title='User9' description='' error={errors.user9?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user9')}
/>
</ModalSplitInput>
<OntimeModalFooter
formId='cuesheet-settings'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,44 @@
import { Switch } from '@chakra-ui/react';
import { useLocalEvent } from '../../../common/stores/localEvent';
import ModalSplitInput from '../ModalSplitInput';
import style from './SettingsModal.module.scss';
export default function EditorSettings() {
const eventSettings = useLocalEvent((state) => state.eventSettings);
const setShowQuickEntry = useLocalEvent((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useLocalEvent((state) => state.setStartTimeIsLastEnd);
const setDefaultPublic = useLocalEvent((state) => state.setDefaultPublic);
return (
<div className={style.sectionContainer}>
<span className={style.title}>Rundown settings</span>
<ModalSplitInput field='' title='Show quick entry' description='Whether quick entry shows under selected event'>
<Switch
variant='ontime-on-light'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowQuickEntry(event.target.checked)}
/>
</ModalSplitInput>
<ModalSplitInput
field=''
title='Start time is last end'
description='New events start time will be previous event end'
>
<Switch
variant='ontime-on-light'
defaultChecked={eventSettings.startTimeIsLastEnd}
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
/>
</ModalSplitInput>
<ModalSplitInput field='' title='Default public' description='New events will be public'>
<Switch
variant='ontime-on-light'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</ModalSplitInput>
</div>
);
}
@@ -0,0 +1,110 @@
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { useEmitLog } from '../../../common/stores/logger';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function EventDataForm() {
const { data, status, refetch } = useEventData();
const { emitError } = useEmitLog();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<EventData>({
defaultValues: data,
values: data,
});
const onSubmit = async (formData: EventData) => {
try {
await postEventData(formData);
} catch (error) {
emitError(`Error saving event settings: ${error}`);
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const disableInputs = status === 'loading';
return (
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Event title'
description='Shown in overview screens'
error={errors.title?.message}
>
<Input
{...inputProps}
variant='ontime-filled-on-light'
maxLength={50}
placeholder='Eurovision song contest'
isDisabled={disableInputs}
{...register('title')}
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
maxLength={150}
placeholder='Shows always start ontime'
isDisabled={disableInputs}
{...register('publicInfo')}
/>
</ModalInput>
<ModalInput field='publicUrl' title='Public URL' description='QR code to be shown on public screens'>
<Input
{...inputProps}
variant='ontime-filled-on-light'
placeholder='www.getontime.no'
isDisabled={disableInputs}
{...register('publicUrl')}
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
maxLength={150}
placeholder='Wi-Fi password: 1234'
isDisabled={disableInputs}
{...register('backstageInfo')}
/>
</ModalInput>
<ModalInput field='backstageUrl' title='Backstage URL' description='QR code to be shown on public screens'>
<Input
{...inputProps}
variant='ontime-filled-on-light'
size='sm'
placeholder='www.ontime.gitbook.io'
isDisabled={disableInputs}
{...register('backstageUrl')}
/>
</ModalInput>
<OntimeModalFooter
formId='event-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,35 @@
import { useController, UseControllerProps } from 'react-hook-form';
import { Input } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { millisToMinutes } from '../../../common/utils/dateConfig';
import { inputProps } from '../modalHelper';
export default function InputMillisWithString(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({
control,
name,
rules: {
pattern: {
value: /^[0-9]+$/,
message: 'Only numbers are valid',
},
},
});
return (
<Input
{...inputProps}
type='number'
variant='ontime-filled-on-light'
width='75px'
size='sm'
maxLength={3}
defaultValue={millisToMinutes(value as number)}
onChange={onChange}
/>
);
}
@@ -0,0 +1,35 @@
import { useState } from 'react';
import { UseFormRegister } from 'react-hook-form';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
interface ModalPinInputProps {
register: UseFormRegister<any>;
formName: string;
isDisabled?: boolean;
}
export default function ModalPinInput({ register, formName, isDisabled }: ModalPinInputProps) {
const [isVisible, setVisible] = useState(false);
return (
<InputGroup size='sm' width='100px'>
<Input
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName)}
placeholder='-'
isDisabled={isDisabled}
/>
<InputRightElement>
<IconButton
onMouseDown={() => setVisible(true)}
onMouseUp={() => setVisible(false)}
size='sm'
variant='ontime-ghost-on-light'
icon={<IoEyeOutline />}
aria-label='Show pin code'
/>
</InputRightElement>
</InputGroup>
);
}
@@ -0,0 +1,21 @@
@import "../Modal.module.scss";
.aliases {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
width: 100%;
padding: 8px 0;
.aliasRow {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
}
.grow {
flex: 1;
}
}
@@ -0,0 +1,55 @@
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import ModalWrapper from '../ModalWrapper';
import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettings from './CuesheetSettings';
import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
isOpen: boolean;
onClose: () => void;
}
export default function SettingsModal(props: ModalManagerProps) {
const { isOpen, onClose } = props;
return (
<ModalWrapper title='Ontime Settings' isOpen={isOpen} onClose={onClose}>
<ModalBody>
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App Settings</Tab>
<Tab>Event Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
<Tab>URL Aliases</Tab>
</TabList>
<TabPanels>
<TabPanel>
<AppSettingsModal />
</TabPanel>
<TabPanel>
<EventDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
</TabPanel>
<TabPanel>
<CuesheetSettings />
</TabPanel>
<TabPanel>
<ViewSettingsForm />
</TabPanel>
<TabPanel>
<AliasesForm />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
</ModalWrapper>
);
}
@@ -0,0 +1,126 @@
import { useForm } from 'react-hook-form';
import { Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { postViewSettings } from '../../../common/api/ontimeApi';
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { mtm } from '../../../common/utils/timeConstants';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
import ModalSplitInput from '../ModalSplitInput';
import OntimeModalFooter from '../OntimeModalFooter';
import InputMillisWithString from './InputMillisWithString';
import style from './SettingsModal.module.scss';
export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings();
const { emitError } = useEmitLog();
const {
control,
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid, dirtyFields },
} = useForm<ViewSettings>({
defaultValues: data,
values: data,
});
const onSubmit = async (formData: ViewSettings) => {
const parsedWarningThreshold = dirtyFields?.warningThreshold
? // @ts-expect-error -- trust me
Number.parseInt(formData.warningThreshold) * mtm
: formData.warningThreshold;
const parsedDangerThreshold = dirtyFields?.dangerThreshold
? // @ts-expect-error -- trust me
Number.parseInt(formData.dangerThreshold) * mtm
: formData.dangerThreshold;
const newData = {
...formData,
warningThreshold: parsedWarningThreshold,
dangerThreshold: parsedDangerThreshold,
};
try {
await postViewSettings(newData);
} catch (error) {
emitError(`Error saving view settings: ${error}`);
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
if (!control) {
return null;
}
const disableInputs = status === 'loading';
return (
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
<span className={style.title}>General view settings</span>
<ModalSplitInput
field='overrideStyles'
title='Override CSS Styles'
description='Enables overriding view styles with custom stylesheet'
>
<Switch {...register('overrideStyles')} variant='ontime-on-light' />
</ModalSplitInput>
<span className={style.title}>Timer view settings</span>
<ModalSplitInput field='normalColor' title='Timer colour' description='Normal colour of a running timer'>
<PopoverPickerRHF name='normalColor' control={control} />
</ModalSplitInput>
<ModalSplitInput
field='warningColor'
title='Warning Color'
description='Time (in minutes) when the timer moves to warning mode'
>
<InputMillisWithString name='warningThreshold' control={control} />
</ModalSplitInput>
<ModalSplitInput field='warningColor' title='Warning Color' description='Colour of timer in warning mode'>
<PopoverPickerRHF name='warningColor' control={control} />
</ModalSplitInput>
<ModalSplitInput
field='dangerThreshold'
title='Danger colour'
description='Time (in minutes) when the timer moves to danger mode'
>
<InputMillisWithString name='dangerThreshold' control={control} />
</ModalSplitInput>
<ModalSplitInput field='dangerColor' title='Timer colour' description='Colour of timer in danger mode'>
<PopoverPickerRHF name='dangerColor' control={control} />
</ModalSplitInput>
<div style={{ height: '16px' }} />
<ModalInput
field='endMessage'
title='End Message'
description='If no end message is provided, timer will continue in overtime mode'
>
<Input
{...inputProps}
variant='ontime-filled-on-light'
maxLength={150}
placeholder='Message to be shown when timer reaches end'
isDisabled={disableInputs}
{...register('endMessage')}
/>
</ModalInput>
<OntimeModalFooter
formId='view-settings'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,91 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@import "../Modal.module.scss";
.uploadBody {
display: flex;
flex-direction: column;
gap: 16px;
}
.uploadArea {
width: 100%;
min-height: 200px;
border: 2px dashed $gray-50;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: gray;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
.uploadOptions {
display: flex;
flex-direction: column;
}
.pad {
margin: 8px;
}
@@ -0,0 +1,168 @@
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import { validateFile } from './utils';
import style from './UploadModal.module.scss';
interface UploadModalProps {
onClose: () => void;
isOpen: boolean;
}
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const handleSubmit = useCallback(async () => {
setSubmitting(true);
if (file) {
try {
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
} catch (error) {
emitError(`Failed uploading file: ${error}`);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => {
fileInputRef.current?.click();
};
const clearFile = () => {
setFile(null);
};
const handleClose = () => {
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
onClose();
};
const disableSubmit = !file || isSubmitting;
return (
<Modal
onClose={handleClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
)}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,25 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
@@ -0,0 +1,32 @@
.eventContainer {
margin-top: 1em;
display: flex;
flex-direction: column;
padding: 8px 4px 8px 0;
overflow-y: scroll;
-ms-overflow-style: -ms-autohiding-scrollbar;
height: 100%;
}
.list {
overflow-x: clip;
display: flex;
flex-direction: column;
}
.empty {
opacity: 0.3;
align-self: center;
}
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 24px;
}
}
.spacer {
min-height: 50vh;
}
@@ -0,0 +1,276 @@
import { lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: OntimeRundown;
}
export default function Rundown(props: RundownProps) {
const { entries } = props;
const [statefulEntries, setStatefulEntries] = useState(entries);
const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction();
const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const showQuickEntry = eventSettings.showQuickEntry;
const isExtracted = window.location.pathname.includes('/rundown');
// cursor
const cursor = useAppMode((state) => state.cursor);
const appMode = useAppMode((state) => state.mode);
const viewFollowsCursor = appMode === AppMode.Run;
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: string | null) => {
if (cursor === null) {
// we cant clone without selection
if (type === 'clone') {
return;
}
// the only thing to do is adding an event at top
addEvent({ type });
return;
}
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent);
newEvent.after = cursorEvent.id;
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
const options = {
defaultPublic: defaultPublic,
startTimeIsLastEnd: startTimeIsLastEnd,
lastEventId: cursor,
after: cursor,
};
addEvent(newEvent, options);
} else {
addEvent({ type }, { after: cursor });
}
},
[addEvent, defaultPublic, entries, startTimeIsLastEnd],
);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// Check if the alt key is pressed
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
switch (event.code) {
case 'ArrowDown': {
if (entries.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor);
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
break;
}
case 'ArrowUp': {
if (entries.length < 1) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor);
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
break;
}
case 'KeyE': {
event.preventDefault();
insertAtCursor(SupportedEvent.Event, cursor);
break;
}
case 'KeyD': {
event.preventDefault();
insertAtCursor(SupportedEvent.Delay, cursor);
break;
}
case 'KeyB': {
event.preventDefault();
insertAtCursor(SupportedEvent.Block, cursor);
break;
}
case 'KeyC': {
event.preventDefault();
insertAtCursor('clone', cursor);
break;
}
}
}
},
[cursor, entries, insertAtCursor, moveCursorTo],
);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
if (entries) {
setStatefulEntries(entries);
}
}, [entries]);
// listen to keys
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
// when cursor moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLDivElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (cursorRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [cursorRef?.current, scrollRef]);
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
return;
}
moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over?.id) {
if (active.id !== over?.id) {
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// ugly hack to handle inconsistencies between dnd-kit and async store updates
setStatefulEntries((currentEntries) => {
return arrayMove(currentEntries, fromIndex, toIndex);
});
reorderEvent(String(active.id), fromIndex, toIndex);
}
}
};
if (statefulEntries?.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
}
let cumulativeDelay = 0;
let previousEnd = 0;
let thisEnd = 0;
let previousEventId: string | undefined;
let eventIndex = -1;
let isPast = Boolean(featureData?.selectedEventId);
return (
<div className={style.eventContainer} ref={scrollRef}>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{statefulEntries.map((entry, index) => {
if (index === 0) {
cumulativeDelay = 0;
eventIndex = -1;
}
if (entry.type === SupportedEvent.Delay && entry.duration !== null) {
cumulativeDelay += entry.duration;
} else if (entry.type === SupportedEvent.Block) {
cumulativeDelay = 0;
} else if (entry.type === SupportedEvent.Event) {
eventIndex++;
previousEnd = thisEnd;
thisEnd = entry.timeEnd;
previousEventId = entry.id;
}
const isLast = index === entries.length - 1;
const isSelected = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
if (isSelected) {
isPast = false;
}
return (
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
eventIndex={eventIndex}
isPast={isPast}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
delay={cumulativeDelay}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
eventId={entry.id}
previousEventId={previousEventId}
disableAddDelay={entry.type === SupportedEvent.Delay}
disableAddBlock={entry.type === SupportedEvent.Block}
/>
)}
</div>
);
})}
<div className={style.spacer} />
</div>
</SortableContext>
</DndContext>
</div>
);
}
@@ -0,0 +1,23 @@
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import Empty from '../../common/components/state/Empty';
import style from './Rundown.module.scss';
interface RundownEmptyProps {
handleAddNew: () => void;
}
export default function RundownEmpty(props: RundownEmptyProps) {
const { handleAddNew } = props;
return (
<div className={style.alignCenter}>
<Empty text='No data yet' style={{ marginTop: '7vh' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
Create Event
</Button>
</div>
);
}
@@ -0,0 +1,190 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
import { calculateDuration } from '../../common/utils/timesManager';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update';
interface RundownEntryProps {
type: SupportedEvent;
eventIndex: number;
isPast: boolean;
data: OntimeRundownEntry;
selected: boolean;
hasCursor: boolean;
next: boolean;
delay: number;
previousEnd: number;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
disableEdit: boolean; // we disable edit when the window is extracted
}
export default function RundownEntry(props: RundownEntryProps) {
const {
eventIndex,
isPast,
data,
selected,
hasCursor,
next,
delay,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const openId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const removeOpenEvent = useCallback(() => {
if (openId === data.id) {
setEditId(null);
}
if (cursor === data.id) {
setCursor(null);
}
}, [cursor, data.id, openId, setCursor, setEditId]);
const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
// Create / delete new events
type FieldValue = {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
};
// we assume the data is not changing in the lifecycle of this component
// changes to the data would make rundown re-render, also re-rendering this component
const actionHandler = useCallback(
(action: EventItemActions, payload?: number | FieldValue) => {
switch (action) {
case 'event': {
const newEvent = { type: SupportedEvent.Event };
const options = {
startTimeIsLastEnd,
defaultPublic,
lastEventId: previousEventId,
after: data.id,
};
addEvent(newEvent, options);
break;
}
case 'delay': {
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
break;
}
case 'block': {
addEvent({ type: SupportedEvent.Block }, { after: data.id });
break;
}
case 'delete': {
if (openId === data.id) {
removeOpenEvent();
}
deleteEvent(data.id);
break;
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
addEvent(newEvent);
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
const newData: Partial<OntimeEvent> = { id: data.id };
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
// duration defines timeEnd
newData.duration = value as number;
newData.timeEnd = data.timeStart + (value as number);
updateEvent(newData);
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(value as number, data.timeEnd);
newData.timeStart = value as number;
updateEvent(newData);
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(data.timeStart, value as number);
newData.timeEnd = value as number;
updateEvent(newData);
} else if (field in data) {
// @ts-expect-error not sure how to type this
newData[field] = value;
updateEvent(newData);
} else {
emitError(`Unknown field: ${field}`);
}
break;
}
default:
throw new Error(`Unhandled event ${action}`);
}
},
[
addEvent,
data,
defaultPublic,
deleteEvent,
emitError,
openId,
previousEventId,
removeOpenEvent,
startTimeIsLastEnd,
updateEvent,
],
);
if (data.type === SupportedEvent.Event) {
return (
<EventBlock
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
eventIndex={eventIndex + 1}
eventId={data.id}
isPublic={data.isPublic}
endAction={data.endAction}
timerType={data.timerType}
title={data.title}
note={data.note}
delay={delay}
previousEnd={previousEnd}
colour={data.colour}
isPast={isPast}
next={next}
skip={data.skip}
selected={selected}
hasCursor={hasCursor}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
/>
);
} else if (data.type === SupportedEvent.Block) {
return <BlockBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
} else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
}
return null;
}
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../common/utils/linkUtils';
import RundownWrapper from './RundownWrapper';
import style from '../editors/Editor.module.scss';
const RundownExport = () => {
return (
<div className={style.rundown} data-testid='panel-rundown'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />
<ErrorBoundary>
<RundownWrapper />
</ErrorBoundary>
</div>
);
};
export default memo(RundownExport);
@@ -0,0 +1,20 @@
import Empty from '../../common/components/state/Empty';
import useRundown from '../../common/hooks-query/useRundown';
import RundownMenu from '../../features/menu/RundownMenu';
import Rundown from './Rundown';
import styles from '../editors/Editor.module.scss';
export default function RundownWrapper() {
const { data, status } = useRundown();
return (
<>
<RundownMenu />
<div className={styles.content}>
{status === 'success' && data ? <Rundown entries={data} /> : <Empty text='Connecting to server' />}
</div>
</>
);
}
@@ -0,0 +1,44 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
$block-gap: 4px;
$block-element-spacing: 4px;
$block-binder-width: 32px;
$block-clearance: 8px;
$block-border-radius: 8px;
$block-text-color: $gray-50;
$block-bg: $gray-1200;
$block-bg2: $gray-1050; // for delay and blocks
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 40px;
$block-cursor-color: $blue-400;
@mixin block-styling() {
box-sizing: content-box;
border: 1px solid $white-10;
font-family: $ontime-font-family;
border-radius: $block-border-radius;
margin: 4px 2px;
position: relative;
color: $block-text-color;
}
@mixin block-spacing() {
padding-right: 8px;
gap: 2px;
}
@mixin drag-style() {
font-size: 20px;
justify-self: center;
opacity: 0.3;
cursor: grab;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
&:focus {
box-shadow: none;
outline: none;
}
}
@@ -0,0 +1,26 @@
@use '../blockMixins' as *;
.block {
@include block-spacing;
@include block-styling;
background-color: $block-bg2;
display: grid;
grid-template-columns: 32px 1fr auto;
align-items: center;
height: $secondary-block-height;
gap: 8px;
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
}
.drag {
@include drag-style;
}
.actionMenu {
justify-self: flex-end;
}
@@ -0,0 +1,65 @@
import { useEffect, useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock, OntimeEvent } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
}
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, actionHandler } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: data.id,
animateLayoutChanges: () => false,
});
const dragStyle = {
transform: CSS.Translate.toString(transform),
transition,
};
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} showAdd showDelay enableDelete actionHandler={actionHandler} />
</div>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss';
interface TitleEditorProps {
title: string;
eventId: string;
placeholder: string;
className?: string;
}
export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props;
const [blockTitle, setBlockTitle] = useState<string>(title || '');
const { updateEvent } = useEventAction();
useEffect(() => {
setBlockTitle(title);
}, [title]);
const handleTitle = useCallback(
(text: string) => {
if (text === title) {
return;
}
const cleanVal = text.trim();
setBlockTitle(cleanVal);
updateEvent({ id: eventId, title: cleanVal });
},
[title, updateEvent, eventId],
);
const classes = cx([className, style.eventTitle, !blockTitle ? style.noTitle : null]);
return (
<Editable
variant='ontime'
value={blockTitle}
className={classes}
placeholder={placeholder}
onChange={(value) => setBlockTitle(value)}
onSubmit={(value) => handleTitle(value)}
>
<EditablePreview className={style.preview} />
<EditableInput />
</Editable>
);
}
@@ -0,0 +1,16 @@
@use '../../../theme/v2Styles' as *;
.titleEditor {
display: block;
font-size: 18px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.noTitle {
.preview {
opacity: $opacity-disabled;
}
}
@@ -0,0 +1,24 @@
@use '../blockMixins' as *;
.delay {
@include block-spacing;
@include block-styling;
background-color: $block-bg2;
display: grid;
grid-template-columns: 32px 1fr auto;
grid-template-areas: 'drag inpt btns';
align-items: center;
height: $secondary-block-height;
gap: 8px;
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
}
.drag {
@include drag-style;
grid-area: drag;
}
@@ -0,0 +1,86 @@
import { useEffect, useRef } from 'react';
import { Button, HStack } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeDelay, OntimeEvent } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './DelayBlock.module.scss';
interface DelayBlockProps {
data: OntimeDelay;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
}
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { applyDelay, deleteEvent } = useEventAction();
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: data.id,
animateLayoutChanges: () => false,
});
const dragStyle = {
transform: CSS.Translate.toString(transform),
transition,
};
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
const applyDelayHandler = () => {
applyDelay(data.id);
};
const cancelDelayHandler = () => {
deleteEvent(data.id);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<DelayInput eventId={data.id} duration={data.duration} />
<HStack spacing='8px' className={style.actionOverlay}>
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmark />} variant='ontime-subtle-white'>
Apply
</Button>
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
Cancel
</Button>
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
</HStack>
</div>
);
}
@@ -0,0 +1,200 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../blockMixins' as *;
$skip-opacity: 0.1;
.eventBlock {
@include block-styling;
background-color: $block-bg;
display: grid;
grid-template-areas:
"binder ... ... ..."
"binder pb-actions times actions"
"binder pb-actions title next"
"binder pb-actions estatus estatus"
"binder ... ... ...";
grid-template-columns: $block-binder-width auto 1fr auto;
grid-template-rows: 4px 36px 36px 36px 4px;
align-items: center;
padding-right: $block-clearance;
gap: 2px;
transition-property: background-color;
transition-duration: $transition-time-feedback;
@mixin declare-overrides() {
--status-color-override: #{$gray-200};
--status-color-active-override: #{$green-400};
}
&.selected {
background-color: $gray-1350;
}
&.play {
background-color: $green-700;
@include declare-overrides;
}
&.roll {
background-color: $blue-700;
@include declare-overrides;
}
&.pause {
background-color: rgba($ontime-paused, 0.6);
@include declare-overrides;
}
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.past:not(.skip) {
opacity: 0.6;
}
&.skip {
border: 1px solid $white-3;
.delayNote,
.eventTitle,
.eventNote,
.binder,
.eventTimers,
.eventStatus {
opacity: $skip-opacity;
}
}
}
.binder {
grid-area: binder;
height: 100%;
display: grid;
place-content: center;
position: relative;
cursor: pointer;
border-radius: $block-border-radius 0 0 $block-border-radius;
background-color: $gray-1050; // to override inline
color: $section-white;
font-size: 17px;
.drag {
@include drag-style;
position: absolute;
margin-top: 4px;
}
}
.playbackActions {
grid-area: pb-actions;
display: flex;
flex-direction: column;
margin: 0 8px;
gap: 6px;
}
.eventTimers {
grid-area: times;
display: flex;
align-items: center;
gap: $block-clearance;
height: 100%;
.delayNote {
font-size: 12px;
line-height: 14px;
color: $ontime-delay-text;
}
}
.eventTitle {
grid-area: title;
}
.eventActions {
grid-area: actions;
display: flex;
gap: $block-clearance;
justify-content: flex-end;
}
.eventOptions {
margin: $element-spacing 16px $element-spacing 0;
}
.progressBg {
grid-area: progb;
border-radius: 2px;
background-color: $gray-1100;
opacity: 1;
height: 100%;
}
.progressBg.hidden {
opacity: 0;
}
.flip {
transform: rotateY(180deg);
}
.statusElements {
grid-area: estatus;
display: grid;
grid-template-areas:
"notes status"
"progb progb";
gap: 2px;
grid-template-rows: auto 4px;
align-items: center;
height: 100%;
padding: 2px 0;
}
.eventNote {
grid-area: notes;
display: block;
font-size: 13px;
color: $block-text-color;
line-height: 13px;
}
.nextTag {
grid-area: next;
font-size: 1em;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
text-align: right;
}
.eventStatus {
grid-area: status;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
color: var(--status-color-override, $gray-500);
.statusIcon {
width: 16px;
height: 16px;
}
.statusIcon.active {
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.disabled {
color: $gray-1000;
}
}
@@ -0,0 +1,177 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useAppMode } from '../../../common/stores/appModeStore';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import EventBlockInner from './EventBlockInner';
import style from './EventBlock.module.scss';
interface EventBlockProps {
timeStart: number;
timeEnd: number;
duration: number;
eventIndex: number;
eventId: string;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
previousEnd: number;
colour: string;
isPast: boolean;
next: boolean;
skip: boolean;
selected: boolean;
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
disableEdit: boolean;
}
export default function EventBlock(props: EventBlockProps) {
const {
timeStart,
timeEnd,
duration,
eventIndex,
eventId,
isPublic = true,
endAction,
timerType,
title,
note,
delay,
previousEnd,
colour,
isPast,
next,
skip = false,
selected,
hasCursor,
playback,
isRolling,
actionHandler,
disableEdit,
} = props;
const moveCursorTo = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const openId = useAppMode((state) => state.editId);
const {
isDragging,
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: eventId,
animateLayoutChanges: () => false,
});
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
const binderColours = colour && getAccessibleColour(colour);
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 1,
},
);
const handleRefCurrent = handleRef.current;
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [handleRef]);
const blockClasses = cx([
style.eventBlock,
skip ? style.skip : null,
isPast ? style.past : null,
selected ? style.selected : null,
playback ? style[playback] : null,
hasCursor ? style.hasCursor : null,
]);
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
moveCursorTo(eventId, true);
};
return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle} onClick={handleFocusClick}>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
{eventIndex}
</div>
{isVisible && (
<EventBlockInner
isOpen={openId === eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
eventId={eventId}
isPublic={isPublic}
endAction={endAction}
timerType={timerType}
title={title}
note={note}
delay={delay}
previousEnd={previousEnd}
next={next}
skip={skip}
selected={selected}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
/>
)}
</div>
);
}
@@ -0,0 +1,197 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useAppMode } from '../../../common/stores/appModeStore';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar';
import EventBlockTimers from './composite/EventBlockTimers';
import style from './EventBlock.module.scss';
const blockBtnStyle = {
size: 'sm',
};
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockInnerProps {
isOpen: boolean;
timeStart: number;
timeEnd: number;
duration: number;
eventId: string;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
previousEnd: number;
next: boolean;
skip: boolean;
selected: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
disableEdit: boolean;
}
const EventBlockInner = (props: EventBlockInnerProps) => {
const {
isOpen,
timeStart,
timeEnd,
duration,
eventId,
isPublic = true,
endAction,
timerType,
title,
note,
delay,
previousEnd,
next,
skip = false,
selected,
playback,
isRolling,
actionHandler,
disableEdit,
} = props;
const [renderInner, setRenderInner] = useState(false);
const setEditId = useAppMode((state) => state.setEditId);
useEffect(() => {
setRenderInner(true);
}, []);
const toggleOpenEvent = useCallback(() => {
if (isOpen) {
setEditId(null);
} else {
setEditId(eventId);
}
}, [eventId, isOpen, setEditId]);
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
const playBtnStyles = { _hover: {} };
if (!skip && eventIsPlaying) {
playBtnStyles._hover = { bg: '#c05621' }; // $ontime-paused
} else if (!skip && !eventIsPlaying) {
playBtnStyles._hover = {};
}
return !renderInner ? null : (
<>
<EventBlockTimers
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
delay={delay}
previousEnd={previousEnd}
/>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{next && (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
<EventBlockPlayback
eventId={eventId}
skip={skip}
isPlaying={eventIsPlaying}
isPaused={eventIsPaused}
selected={selected}
disablePlayback={skip || isRolling}
/>
<div className={style.statusElements}>
<span className={style.eventNote}>{note}</span>
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{selected && <EventBlockProgressBar playback={playback} />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
<span>
<TimerIcon type={timerType} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
<span>
<EndActionIcon action={endAction} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span>
</Tooltip>
</div>
</div>
<div className={style.eventActions}>
<TooltipActionBtn
{...blockBtnStyle}
variant='ontime-subtle-white'
size='sm'
icon={<IoOptions />}
clickHandler={toggleOpenEvent}
tooltip='Event options'
aria-label='Event options'
tabIndex={-1}
backgroundColor={isOpen ? '#2B5ABC' : undefined}
color={isOpen ? 'white' : '#f6f6f6'}
isDisabled={disableEdit}
/>
<BlockActionMenu showAdd showDelay showBlock showClone enableDelete={!selected} actionHandler={actionHandler} />
</div>
</>
);
};
export default memo(EventBlockInner);
function EndActionIcon(props: { action: EndAction; className: string }) {
const { action, className } = props;
if (action === EndAction.LoadNext) {
return <IoPlaySkipForward className={className} />;
}
if (action === EndAction.PlayNext) {
return <IoPlayForward className={className} />;
}
if (action === EndAction.Stop) {
return <IoStop className={className} />;
}
return <IoPlay className={className} />;
}
function TimerIcon(props: { type: TimerType; className: string }) {
const { type, className } = props;
if (type === TimerType.CountUp) {
return <IoArrowUp className={className} />;
}
if (type === TimerType.Clock) {
return <IoTime className={className} />;
}
return <IoArrowDown className={className} />;
}
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
showAdd?: boolean;
showDelay?: boolean;
showBlock?: boolean;
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
className?: string;
}
export default function BlockActionMenu(props: BlockActionMenuProps) {
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]);
const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]);
const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]);
return (
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
<MenuButton
as={IconButton}
aria-label='Event options'
icon={<IoEllipsisHorizontal />}
tabIndex={-1}
variant='ontime-subtle-white'
size='sm'
className={className}
/>
</Tooltip>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}>
Add Event after
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay} isDisabled={!showDelay}>
Add Delay after
</MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock} isDisabled={!showBlock}>
Add Block after
</MenuItem>
{showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone} isDisabled={!showBlock}>
Clone event
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
Delete
</MenuItem>
</MenuList>
</Menu>
);
}
@@ -0,0 +1,125 @@
import { memo } from 'react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import style from '../EventBlock.module.scss';
const blockBtnStyle = {
size: 'sm',
};
type StyleVariant = {
'aria-label': string;
tooltip: string;
backgroundColor: string;
_hover: { backgroundColor?: string };
};
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockPlaybackProps {
eventId: string;
skip: boolean;
isPlaying: boolean;
isPaused: boolean;
selected: boolean;
disablePlayback: boolean;
}
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, selected, disablePlayback } = props;
const { updateEvent } = useEventAction();
const toggleSkip = () => {
updateEvent({ id: eventId, skip: !skip });
};
const actionHandler = () => {
// is playing -> pause
// is paused -> continue
// otherwise -> start
if (isPlaying) {
setEventPlayback.pause();
} else if (isPaused) {
setEventPlayback.start();
} else {
setEventPlayback.startEvent(eventId);
}
};
const buttonVariant: Partial<StyleVariant> = {};
if (isPaused) {
// continue
buttonVariant['aria-label'] = 'Continue event';
buttonVariant.tooltip = 'Continue event';
buttonVariant.backgroundColor = '#339E4E';
buttonVariant._hover = { backgroundColor: '#339E4Eee' };
} else if (isPlaying) {
// pause
buttonVariant['aria-label'] = 'Pause event';
buttonVariant.tooltip = 'Pause event';
buttonVariant.backgroundColor = '#c05621';
buttonVariant._hover = { backgroundColor: '#c05621ee' };
} else {
// start
buttonVariant['aria-label'] = 'Start event';
buttonVariant.tooltip = 'Start event';
if (!disablePlayback) {
buttonVariant._hover = { backgroundColor: '#339E4E' };
}
}
return (
<div className={style.playbackActions}>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Skip event'
tooltip='Skip event'
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
backgroundColor={skip ? '#FA5656' : undefined}
_hover={{ backgroundColor: skip ? '#FF7878' : '#404040' }}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={toggleSkip}
tabIndex={-1}
isDisabled={selected}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Load event'
tooltip='Load event'
icon={<IoReload className={style.flip} />}
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={() => setEventPlayback.loadEvent(eventId)}
tabIndex={-1}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
aria-label='Start event'
tooltip='Start event'
icon={!isPlaying ? <IoPlay /> : <IoPause />}
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
{...buttonVariant}
clickHandler={actionHandler}
tabIndex={-1}
/>
</div>
);
};
export default memo(EventBlockPlayback);
@@ -0,0 +1,26 @@
@use '../../../../theme/v2Styles' as *;
.progressBar {
height: 100%;
width: 0;
border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
&.play {
background-color: $playback-start;
}
&.overtime {
background-color: $playback-negative;
}
&.pause {
background-color: $ontime-paused;
}
&.roll {
background-color: $ontime-roll;
}
}
@@ -0,0 +1,26 @@
import { Playback } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math';
import style from './EventBlockProgressBar.module.scss';
interface EventBlockProgressBarProps {
playback?: Playback;
}
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
const { playback } = props;
const timer = useTimer();
const now = Math.floor(Math.max((timer?.current ?? 1) / 1000, 0));
const complete = (timer?.duration ?? 1) / 1000;
const elapsed = clamp(100 - (now * 100) / complete, 0, 100);
const progress = `${elapsed}%`;
if ((timer?.current ?? 0) < 0) {
return <div className={`${style.progressBar} ${style.overtime}`} style={{ width: '100%' }} />;
}
return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />;
}
@@ -0,0 +1,114 @@
import { memo, useCallback, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
previousEnd: number;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride';
const EventBlockTimers = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const handleSubmit = (field: TimeActions, value: number) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value;
newEventData.timeEnd = timeStart + value;
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value, timeEnd);
newEventData.timeStart = value;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(timeStart, value);
newEventData.timeEnd = value;
break;
}
}
updateEvent(newEventData);
};
/**
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidation = useCallback(
(field: TimeEntryField, value: number) => {
const valid = validateEntry(field, value, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
},
[timeEnd, timeStart],
);
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
return (
<div className={style.eventTimers}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
warning={warning.start}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
warning={warning.end}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={duration}
delay={0}
placeholder='Duration'
previousEnd={previousEnd}
warning={warning.duration}
/>
{delayTime && (
<div className={style.delayNote}>
{delayTime}
<br />
{`New start: ${newTime}`}
</div>
)}
</div>
);
};
export default memo(EventBlockTimers);
@@ -0,0 +1,35 @@
@use '../../../theme/v2Styles' as *;
.quickAdd {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
margin: 4px 0;
font-size: 12px;
padding: 0 10px;
}
.btnRow {
justify-self: center;
display: flex;
gap: 10%;
.quickBtn {
font-weight: 400;
width: auto;
padding: 0 32px;
}
}
.keyboard {
margin-left: 8px;
padding: 0 4px;
color: $label-gray;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.1);
}
.options {
display: flex;
flex-direction: column;
}
@@ -0,0 +1,122 @@
import { memo, useCallback, useRef } from 'react';
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useLocalEvent } from '../../../common/stores/localEvent';
import { useEmitLog } from '../../../common/stores/logger';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps {
showKbd: boolean;
eventId: string;
previousEventId?: string;
disableAddDelay?: boolean;
disableAddBlock: boolean;
}
const QuickAddBlock = (props: QuickAddBlockProps) => {
const { showKbd, eventId, previousEventId, disableAddDelay = true, disableAddBlock } = props;
const { addEvent } = useEventAction();
const { emitError } = useEmitLog();
const doStartTime = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null);
const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const handleCreateEvent = useCallback(
(eventType: SupportedEvent) => {
switch (eventType) {
case 'event': {
const isPublicOption = doPublic?.current?.checked;
const startTimeIsLastEndOption = doStartTime?.current?.checked;
const newEvent = { type: SupportedEvent.Event };
const options = {
defaultPublic: isPublicOption,
startTimeIsLastEnd: startTimeIsLastEndOption,
lastEventId: previousEventId,
after: eventId,
};
addEvent(newEvent, options);
break;
}
case 'delay': {
const options = {
lastEventId: previousEventId,
after: eventId,
};
addEvent({ type: SupportedEvent.Delay }, options);
break;
}
case 'block': {
const options = {
lastEventId: previousEventId,
after: eventId,
};
addEvent({ type: SupportedEvent.Block }, options);
break;
}
default: {
emitError(`Cannot create unknown event type: ${eventType}`);
break;
}
}
},
[previousEventId, eventId, addEvent, emitError],
);
return (
<div className={style.quickAdd}>
<div className={style.btnRow}>
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Event)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
>
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
</Button>
</Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
size='xs'
variant='ontime-subtle-white'
disabled={disableAddDelay}
className={style.quickBtn}
>
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
</Button>
</Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Block)}
size='xs'
variant='ontime-subtle-white'
disabled={disableAddBlock}
className={style.quickBtn}
>
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
</Button>
</Tooltip>
</div>
<div className={style.options}>
<Checkbox ref={doStartTime} size='sm' variant='ontime-ondark' defaultChecked={startTimeIsLastEnd}>
Start time is last end
</Checkbox>
<Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}>
Event is public
</Checkbox>
</div>
</div>
);
};
export default memo(QuickAddBlock);
@@ -0,0 +1,245 @@
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useBlockLayout, useColumnOrder, useResizeColumns, useTable } from 'react-table';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../ontimeConfig';
import SortableCell from './tableElements/SortableCell';
import TableSettings from './tableElements/TableSettings';
import BlockRow from './tableRows/BlockRow';
import DelayRow from './tableRows/DelayRow';
import EventRow from './tableRows/EventRow';
import { makeColumns } from './columns';
import { defaultColumnOrder, defaultHiddenColumns } from './defaults';
import style from './Table.module.scss';
export default function OntimeTable({ tableData, userFields, selectedId, handleUpdate }) {
const { followSelected, showSettings } = useContext(TableSettingsContext);
const [columnOrder, saveColumnOrder] = useLocalStorage('table-order', defaultColumnOrder);
const [columnSize, saveColumnSize] = useLocalStorage('table-sizes', {});
const [hiddenColumns, saveHiddenColumns] = useLocalStorage('table-hidden', defaultHiddenColumns);
const columns = useMemo(() => makeColumns(columnSize, userFields), [columnSize, userFields]);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
setColumnOrder,
allColumns,
setHiddenColumns,
toggleHideAllColumns,
state,
} = useTable(
{
columns,
data: tableData,
initialState: {
hiddenColumns,
},
handleUpdate,
},
useColumnOrder,
useBlockLayout,
useResizeColumns
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleResetReordering = useCallback(() => {
saveColumnOrder(defaultColumnOrder);
setColumnOrder(defaultColumnOrder);
}, [saveColumnOrder, setColumnOrder]);
const handleResetResizing = useCallback(() => {
saveColumnSize({});
}, [saveColumnSize]);
const handleResetToggles = useCallback(() => {
setHiddenColumns(defaultHiddenColumns);
saveHiddenColumns(defaultHiddenColumns);
}, [saveHiddenColumns, setHiddenColumns]);
const clearToggles = useCallback(() => {
toggleHideAllColumns(false);
saveHiddenColumns([]);
}, [saveHiddenColumns, toggleHideAllColumns]);
const handleOnDragEnd = useCallback((event) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
const cols = [...columnOrder];
// get index of from
const fromIndex = cols.findIndex((i) => i === active.id);
// get index of to
const toIndex = cols.findIndex((i) => i === over.id);
if (toIndex === -1) {
return;
}
// reorder
const [reorderedItem] = cols.splice(fromIndex, 1);
cols.splice(toIndex, 0, reorderedItem);
saveColumnOrder(cols);
setColumnOrder(cols);
}, [columnOrder, saveColumnOrder, setColumnOrder]);
// save hidden columns object to local storage
useEffect(() => {
saveHiddenColumns(state.hiddenColumns);
}, [saveHiddenColumns, state.hiddenColumns]);
// save column sizes to local storage
useEffect(() => {
// property changes from title of column to null on resize end
if (state.columnResizing?.isResizingColumn !== null) {
return;
}
const cols = state.columnResizing.columnWidths;
saveColumnSize((prev) => ({ ...prev, ...cols }));
}, [saveColumnSize, state.columnResizing]);
// scroll to active cue
useEffect(() => {
if (followSelected) {
const el = document.getElementById(selectedId);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
}, [followSelected, selectedId]);
// keep order of events
let eventIndex = 0;
// keep delay (ms)
let cumulativeDelay = 0;
return (
<>
{showSettings && (
<TableSettings
columns={allColumns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleResetToggles={handleResetToggles}
handleClearToggles={clearToggles}
/>
)}
<table {...getTableProps()} className={style.ontimeTable}>
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps();
return (
<DndContext
key={key}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleOnDragEnd}
>
<tr {...restHeaderGroupProps}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext
key={key}
items={headerGroup.headers}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((column) => {
const { key } = column.getHeaderProps();
return <SortableCell key={key} column={column} />;
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<tbody {...getTableBodyProps} className={style.tableBody}>
{/*This is saving in place of a default component*/}
{/* eslint-disable-next-line array-callback-return */}
{rows.map((row) => {
prepareRow(row);
const { key } = row.getRowProps();
const type = row.original.type;
if (type === 'event') {
eventIndex++;
return (
<EventRow
key={key}
row={row}
index={eventIndex}
selectedId={selectedId}
delay={cumulativeDelay}
/>
);
}
if (type === 'delay') {
if (row.original.duration != null) {
cumulativeDelay += row.original.duration;
}
return <DelayRow key={key} row={row} />;
}
if (type === 'block') {
cumulativeDelay = 0;
return <BlockRow key={key} row={row} />;
}
})}
</tbody>
</table>
</>
);
}
OntimeTable.propTypes = {
tableData: PropTypes.array,
userFields: PropTypes.object,
handleUpdate: PropTypes.func.isRequired,
selectedId: PropTypes.string,
showSettings: PropTypes.bool,
followSelected: PropTypes.bool,
};
@@ -0,0 +1,14 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { TableSettingsProvider } from '../../common/context/TableSettingsContext';
import TableWrapper from './TableWrapper';
export default function ProtectedTable() {
return (
<ProtectRoute permission='operator'>
<TableSettingsProvider>
<TableWrapper />
</TableSettingsProvider>
</ProtectRoute>
);
}
@@ -0,0 +1,310 @@
@use '../../theme/main' as *;
.tableWrapper,
.tableWrapper__dark {
font-family: "Open Sans", sans-serif;
font-size: 16px;
width: 100%;
height: 100vh;
padding: 2rem;
display: grid;
grid-template-columns: calc(100vw - 4rem);
grid-template-rows: auto auto 1fr;
grid-template-areas:
'header'
'settings'
'table';
gap: 1rem;
overflow: scroll;
& > * {
width: 100%;
border: 1px solid $ontime-pink;
border-radius: 4px;
}
.header {
grid-area: header;
display: grid;
height: max-content;
grid-template-areas:
'name playback running time actions'
'now playback running time actions';
grid-template-columns: 1fr auto 10em 12.5em auto;
align-items: center;
padding: 0.25em 1em;
.headerName {
grid-area: name;
font-size: 1.5em;
}
.headerName:after {
content: '\200b';
}
.headerNow {
grid-area: now;
font-size: 1.25em;
}
.headerNow:after {
content: '\200b';
}
.headerPlayback {
grid-area: playback;
color: $ontime-pink;
text-align: center;
svg {
font-size: 2em;
}
}
.headerRunning {
grid-area: running;
text-align: center;
}
.headerClock {
grid-area: time;
text-align: center;
}
.headerActions {
grid-area: actions;
display: flex;
gap: 8px;
font-size: 1.5em;
padding-left: 10vw;
color: darken($ontime-pink, 8%);
}
}
.tableSettings {
grid-area: settings;
padding: 1rem;
.options,
.buttonRow {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 2rem;
}
.options {
padding-left: 0.5em;
}
.buttonRow {
padding-top: 2em;
}
}
.ontimeTable {
grid-area: table;
border-collapse: separate;
border-spacing: 16px;
padding: 1rem;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
th, td {
touch-action: auto;
padding: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
td {
border-radius: 4px;
}
.tableHeader {
position: sticky;
top: -1em;
z-index: 10;
th {
font-size: 0.9em;
font-weight: 200;
text-align: left;
.resizer {
display: inline-block;
width: 10px;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
z-index: 1;
touch-action: none;
}
}
}
.tableBody {
tr {
td:hover {
background-color: lighten($ontime-pink, 10%) !important;
color: black;
}
}
.selected > td {
background-color: rgba($ontime-accent, 0.8);
}
}
.indexColumn {
font-weight: 200;
text-align: right;
width: 2.5em;
background: transparent;
}
.blockCell,
.delayCell {
width: 100%;
font-weight: 400;
font-size: 0.9em;
text-align: center;
color: black;
}
.delayCell {
background-color: $block-delay-color;
}
.blockCell {
background-color: $block-block-color;
}
}
}
$bg-theme-light: #fcfcfc;
$cell-theme-light: #ececec;
$text-theme-light: #202020;
$bg-theme-dark: #121212;
$bg2-theme-dark: #1c1c1c;
$cell-theme-dark: #2d2d2d;
$text-theme-dark: white;
.tableWrapper {
background-color: $bg-theme-light;
color: black;
* {
background-color: $bg-theme-light;
scrollbar-color: $ontime-pink rgba(0, 0, 0, 0.13);
}
*::-webkit-scrollbar {
background-color: rgba(0, 0, 0, 0.07);
}
*::-webkit-scrollbar-thumb {
background-color: lighten($ontime-pink, 15%);
}
td {
background-color: $cell-theme-light;
border: 1px solid $bg-theme-light;
color: #121212;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: black;
transition: 300ms;
}
}
.tableWrapper__dark {
background-color: $bg-theme-dark;
color: white;
* {
background-color: $bg-theme-dark;
scrollbar-color: $ontime-pink rgba(255, 255, 255, 0.13);
}
td {
background-color: $cell-theme-dark;
border: 1px solid $bg-theme-dark;
color: #ececec;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: white;
transition: 300ms;
}
}
.timer {
font-size: 1.7em;
letter-spacing: 1px;
line-height: 1.2em;
font-weight: 200;
}
.label {
color: $ontime-pink;
font-size: 0.8em;
font-weight: 200;
line-height: 0.75em;
}
svg {
background-color: inherit !important;
}
@mixin action-element() {
cursor: pointer;
}
.actionIcon {
@include action-element();
}
.actionText {
@include action-element();
font-size: 0.65em;
}
.actionDisabled {
@include action-element();
opacity: 0.6;
}
.dragging {
border: 1px solid $ontime-pink;
z-index: 10;
}
.check {
font-size: 1.5em;
background-color: transparent !important;
margin: 0 auto;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,106 @@
import { useContext } from 'react';
import { Divider, Tooltip } from '@chakra-ui/react';
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoMoon } from '@react-icons/all-files/io5/IoMoon';
import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import useFullscreen from '../../common/hooks/useFullscreen';
import { useTimer } from '../../common/hooks/useSocket';
import useEventData from '../../common/hooks-query/useEventData';
import { formatDisplay } from '../../common/utils/dateConfig';
import { formatTime } from '../../common/utils/time';
import { tooltipDelayFast } from '../../ontimeConfig';
import PlaybackIcon from './tableElements/PlaybackIcon';
import style from './Table.module.scss';
export default function TableHeader({ handleCSVExport, featureData }) {
const { followSelected, showSettings, toggleTheme, toggleSettings, toggleFollow } = useContext(TableSettingsContext);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = timer.current < 0;
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.headerName}>{event?.title || ''}</div>
<div className={style.headerNow}>{featureData.titleNow}</div>
<div className={style.headerPlayback}>
<span className={style.label}>{selected}</span>
<br />
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.headerRunning}>
<span className={style.label}>Running Timer</span>
<br />
<span className={style.timer}>{timerNow}</span>
</div>
<div className={style.headerClock}>
<span className={style.label}>Time Now</span>
<br />
<span className={style.timer}>{timeNow}</span>
</div>
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Follow selected'>
<span className={followSelected ? style.actionIcon : style.actionDisabled}>
<FiTarget onClick={() => toggleFollow()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Show settings'>
<span className={showSettings ? style.actionIcon : style.actionDisabled}>
<FiSettings onClick={() => toggleSettings()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle dark mode'>
<span className={style.actionIcon}>
<IoMoon onClick={() => toggleTheme()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span className={style.actionIcon}>
{isFullScreen ? (
<IoContract onClick={() => toggleFullScreen()} />
) : (
<IoExpand onClick={() => toggleFullScreen()} />
)}
</span>
</Tooltip>
<Divider />
<Tooltip openDelay={tooltipDelayFast} label='Export to CSV'>
<span className={style.actionText} onClick={() => handleCSVExport(event)}>
CSV
</span>
</Tooltip>
</div>
</div>
);
}
TableHeader.propTypes = {
handleCSVExport: PropTypes.func.isRequired,
featureData: PropTypes.shape({
playback: PropTypes.string,
selectedEventId: PropTypes.string,
selectedEventIndex: PropTypes.number,
numEvents: PropTypes.number,
titleNow: PropTypes.string,
}),
};
@@ -0,0 +1,97 @@
import { useCallback, useContext, useEffect } from 'react';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import OntimeTable from './OntimeTable';
import TableHeader from './TableHeader';
import { makeCSV, makeTable } from './tableUtils';
import style from './Table.module.scss';
export default function TableWrapper() {
const { data: rundown } = useRundown();
const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction();
const featureData = useCuesheet();
const { theme } = useContext(TableSettingsContext);
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
const handleUpdate = useCallback(
async (rowIndex, accessor, payload) => {
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = rundown[rowIndex];
if (event == null) {
return;
}
if (event[accessor] === payload) {
return;
}
// check if value is valid
// as of now, the fields do not have any validation
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// submit
try {
await updateEvent(mutationObject);
} catch (error) {
console.error(error);
}
},
[updateEvent, rundown],
);
const exportHandler = useCallback(
(headerData) => {
if (!headerData || !rundown || !userFields) {
return;
}
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', 'ontime export.csv');
document.body.appendChild(link);
link.click();
},
[rundown, userFields],
);
if (typeof rundown === 'undefined' || typeof userFields === 'undefined') {
return <span>loading...</span>;
}
return (
<div className={theme === 'dark' ? style.tableWrapper__dark : style.tableWrapper} data-testid='cuesheet'>
<TableHeader handleCSVExport={exportHandler} featureData={featureData} />
<OntimeTable
tableData={rundown}
userFields={userFields}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
/>
</div>
);
}
@@ -0,0 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Schedule Template",
],
[
"Event Name",
"",
],
[
"Public URL",
"",
],
[
"Backstage URL",
"",
],
[],
[
"Time Start",
"Time End",
"Event Title",
"Presenter Name",
"Event Subtitle",
"Is Public? (x)",
"Notes",
"Colour",
"user0:test",
],
[
"00:00:00",
"00:00:00",
"test title 1",
"",
"",
"x",
"",
"",
"test",
"test",
"",
"",
"",
"",
"",
"",
"",
"",
],
]
`;
@@ -0,0 +1,93 @@
import { makeCSV, makeTable, parseField } from '../tableUtils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => {
const testData1 = 1000;
const testData2 = 60000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
});
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
const testTruthy = [1, true, 'x', 'test'];
const testFalsy = ['', null, undefined, false, 0];
testTruthy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('x');
});
});
testFalsy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('');
});
});
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter', undefined)).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: 123 },
{ field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' },
{ field: 'notes', value: 'test' },
{ field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' },
{ field: 'user2', value: 'test' },
{ field: 'user3', value: 'test' },
{ field: 'user4', value: 'test' },
{ field: 'user5', value: 'test' },
{ field: 'user6', value: 'test' },
{ field: 'user7', value: 'test' },
{ field: 'user8', value: 'test' },
{ field: 'user9', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {};
const tableData = [
{
title: 'test title 1',
presenter: '',
timeStart: 0,
timeEnd: 0,
isPublic: 'x',
user0: 'test',
user1: 'test',
},
];
const userFields = {
user0: 'test',
};
const table = makeTable(headerData, tableData, userFields);
expect(table).toMatchSnapshot();
});
});
describe('make CSV()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
"data:text/csv;charset=utf-8,field
after newline,after comma
,after empty
"
`);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
import EditableCell from './tableElements/EditableCell';
import style from './Table.module.scss';
import { millisToString } from 'ontime-utils';
/**
* React - Table column object
* @param sizes
* @param userFields
*/
export const makeColumns = (sizes, userFields) => {
return [
{
Header: 'Public',
accessor: 'isPublic',
Cell: ({ cell: { value } }) => (value ? <FiCheck className={style.check} /> : ''),
width: sizes?.isPublic || 50,
},
{
Header: 'Start',
accessor: 'timeStart',
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
width: sizes?.timeStart || 90,
},
{
Header: 'End',
accessor: 'timeEnd',
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
width: sizes?.timeEnd || 90,
},
{
Header: 'Duration',
accessor: 'duration',
Cell: ({ cell: { value } }) => millisToString(value),
width: sizes?.duration || 90,
},
{ Header: 'Title', accessor: 'title', width: sizes?.title || 400 },
{ Header: 'Subtitle', accessor: 'subtitle', width: sizes?.subtitle || 350 },
{ Header: 'Presenter', accessor: 'presenter', width: sizes?.presenter || 250 },
{ Header: 'Notes', accessor: 'note', width: sizes?.note || 500 },
{
Header: userFields.user0 || 'User 0',
accessor: 'user0',
Cell: EditableCell,
width: sizes?.user0 || 200,
},
{
Header: userFields.user1 || 'User 1',
accessor: 'user1',
Cell: EditableCell,
width: sizes?.user1 || 200,
},
{
Header: userFields.user2 || 'User 2',
accessor: 'user2',
Cell: EditableCell,
width: sizes?.user2 || 200,
},
{
Header: userFields.user3 || 'User 3',
accessor: 'user3',
Cell: EditableCell,
width: sizes?.user3 || 200,
},
{
Header: userFields.user4 || 'User 4',
accessor: 'user4',
Cell: EditableCell,
width: sizes?.user4 || 200,
},
{
Header: userFields.user5 || 'User 5',
accessor: 'user5',
Cell: EditableCell,
width: sizes?.user5 || 200,
},
{
Header: userFields.user6 || 'User 6',
accessor: 'user6',
Cell: EditableCell,
width: sizes?.user6 || 200,
},
{
Header: userFields.user7 || 'User 7',
accessor: 'user7',
Cell: EditableCell,
width: sizes?.user7 || 200,
},
{
Header: userFields.user8 || 'User 8',
accessor: 'user8',
Cell: EditableCell,
width: sizes?.user8 || 200,
},
{
Header: userFields.user9 || 'User 9',
accessor: 'user9',
Cell: EditableCell,
width: sizes?.user9 || 200,
},
];
};
@@ -0,0 +1,39 @@
/**
* @description set default column order
*/
export const defaultColumnOrder = [
'isPublic',
'timeStart',
'timeEnd',
'duration',
'title',
'subtitle',
'presenter',
'note',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
/**
* @description set default hidden columns
*/
export const defaultHiddenColumns = [
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
@@ -0,0 +1,56 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { AutoTextArea } from '@/common/components/input/auto-text-area/AutoTextArea';
import { TableSettingsContext } from '@/common/context/TableSettingsContext';
import PropTypes from 'prop-types';
/**
* Shamelessly copied from react-table docs
* Plugged into chakra-ui editable component
* @description Custom editable field for table component
* @param props
* @return {JSX.Element}
* @constructor
*/
export default function EditableCell(props) {
const {
value: initialValue,
row: { index },
column: { id },
handleUpdate,
} = props;
const { theme } = useContext(TableSettingsContext);
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const onChange = useCallback((e) => setValue(e.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(index, id, value), [handleUpdate, id, index, value]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
onChange={onChange}
onBlur={onBlur}
rows={3}
transition='none'
spellCheck={false}
isDark={theme === "dark"}
/>
);
}
EditableCell.propTypes = {
value: PropTypes.string,
row: PropTypes.object,
column: PropTypes.object,
handleUpdate: PropTypes.func,
};
@@ -0,0 +1,50 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
if (state === Playback.Stop) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
<IoStop />
</Tooltip>
);
}
if (state === Playback.Play) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
<IoPlay />
</Tooltip>
);
}
if (state === Playback.Pause) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
<IoPause />
</Tooltip>
);
}
if (state === Playback.Roll) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
<IoTimeOutline />
</Tooltip>
);
}
return '';
}

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