mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +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,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);
|
||||
Reference in New Issue
Block a user