mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 05:13:32 +00:00
v2 (#386)
* 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:
@@ -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);
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
+26
@@ -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
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user