mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 23:43:57 +00:00
feat: operator view
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.drawerContent {
|
||||
background-color: $gray-1200;
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserFields } from 'ontime-types';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
export const TIME_FORMAT_OPTION: ParamField = {
|
||||
@@ -202,30 +204,38 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const OPERATOR_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to events that have passed',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a field to highlight',
|
||||
type: 'option',
|
||||
values: {
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
user2: 'user2',
|
||||
user3: 'user3',
|
||||
user4: 'user4',
|
||||
user5: 'user5',
|
||||
user6: 'user6',
|
||||
user7: 'user7',
|
||||
user8: 'user8',
|
||||
user9: 'user9',
|
||||
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
||||
return [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'showseconds',
|
||||
title: 'Show seconds',
|
||||
description: 'Schedule shows hh:mm:ss',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to events that have passed',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a field to highlight',
|
||||
type: 'option',
|
||||
values: {
|
||||
user0: userFields.user0 || 'user0',
|
||||
user1: userFields.user1 || 'user1',
|
||||
user2: userFields.user2 || 'user2',
|
||||
user3: userFields.user3 || 'user3',
|
||||
user4: userFields.user4 || 'user4',
|
||||
user5: userFields.user5 || 'user5',
|
||||
user6: userFields.user6 || 'user6',
|
||||
user7: userFields.user7 || 'user7',
|
||||
user8: userFields.user8 || 'user8',
|
||||
user9: userFields.user9 || 'user9',
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function getMatches(query: string): boolean {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
// TODO: debounce handleChange
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(getMatches(query));
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
setMatches(getMatches(query));
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const matchMedia = window.matchMedia(query);
|
||||
|
||||
// Triggered at the first client-side load and if query changes
|
||||
handleChange();
|
||||
|
||||
// Listen matchMedia
|
||||
matchMedia.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
matchMedia.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [handleChange, query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Color from 'color';
|
||||
type ColourCombination = {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Selects text colour to maintain accessible contrast
|
||||
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
||||
console.log(`Unable to parse colour: ${bgColour}`);
|
||||
}
|
||||
}
|
||||
return { backgroundColor: '#000', color: "#fffffa" };
|
||||
return { backgroundColor: '#000', color: '#fffffa' };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates a list of classnames from array of css module conditions
|
||||
* @param classNames - css modules objects
|
||||
*/
|
||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
|
||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||
|
||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
border-top: 1px solid $white-20;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: $gray-1200;
|
||||
background-color: $gray-1250;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
|
||||
@@ -4,29 +4,26 @@
|
||||
.operatorContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: white;
|
||||
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.operatorEvents {
|
||||
flex: 1;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@mixin event-block() {
|
||||
height: 40px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin: 0.1rem;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@mixin block() {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
.block {
|
||||
@include event-block();
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { UIEvent, useState } from 'react';
|
||||
import { UIEvent, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SupportedEvent, UserFields } from 'ontime-types';
|
||||
import { getLastEvent } from 'ontime-utils';
|
||||
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { OPERATOR_OPTIONS } from '../../common/components/view-params-editor/constants';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
|
||||
import FocusBlock from './focus-block/focus-block';
|
||||
import OpBlock from './op-block/OpBlock';
|
||||
import OpEvent from './op-event/OpEvent';
|
||||
import TimeBlock from './time-block/TimeBlock';
|
||||
import { getRundownEnd } from './operator.utils';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
|
||||
import style from './Operator.module.scss';
|
||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||
const featureData = useOperator();
|
||||
const [showChild, setShowChild] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Operator';
|
||||
}, []);
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLElement>) => {
|
||||
const scrollThreshold = 50;
|
||||
const scrollPosition = (event.target as HTMLElement).scrollTop;
|
||||
@@ -29,49 +38,45 @@ export default function Operator() {
|
||||
setShowChild(scrollPosition > scrollThreshold);
|
||||
};
|
||||
|
||||
if (!data || status === 'loading') {
|
||||
return <>loading</>;
|
||||
if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||
const lastEvent = getRundownEnd(data);
|
||||
let eventIndex = 0;
|
||||
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||
const lastEvent = getLastEvent(data);
|
||||
|
||||
const operatorOptions = getOperatorOptions(userFields);
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={OPERATOR_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
|
||||
<div className={style.operatorEvents} onScroll={handleScroll}>
|
||||
{data.map((entry) => {
|
||||
if (entry.type === SupportedEvent.Event) {
|
||||
eventIndex += 1;
|
||||
return (
|
||||
<OpEvent
|
||||
<OperatorEvent
|
||||
key={entry.id}
|
||||
index={eventIndex}
|
||||
cue={entry.cue}
|
||||
data={entry}
|
||||
isSelected={featureData.selectedEventId === entry.id}
|
||||
subscribed={subscribe || undefined}
|
||||
subscribed={subscribe}
|
||||
showSeconds={showSeconds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === SupportedEvent.Block) {
|
||||
return (
|
||||
// @arihanv
|
||||
// we prefer moving these wrapper divs to the block
|
||||
// so that the styles and all attributes can be self-contained
|
||||
<div key={entry.id} className={style.block}>
|
||||
<OpBlock data={entry} />
|
||||
</div>
|
||||
);
|
||||
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{showChild && <FocusBlock />}
|
||||
</div>
|
||||
<TimeBlock playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
|
||||
<FollowButton isVisible={showChild} onClickHandler={() => undefined} />
|
||||
<StatusBar playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
|
||||
import style from './focusBlock.module.scss';
|
||||
|
||||
export default function FocusBlock() {
|
||||
const handleClick = () => console.log('click follow');
|
||||
|
||||
// @arihavn, can we find a way to have a single button here, no need for the div
|
||||
return (
|
||||
<div className={style.focusBlock}>
|
||||
<button className={style.focusButton} onClick={handleClick}>
|
||||
<IoLocate size={16} />
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
|
||||
.focusBlock {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: sticky;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.focusButton {
|
||||
margin: 10px;
|
||||
background-color: $blue-500;
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 99px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.followButton {
|
||||
position: relative;
|
||||
bottom: 17.5vh;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
background-color: $blue-700;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
border-radius: 99px;
|
||||
transition: bottom 1s;
|
||||
|
||||
&:active {
|
||||
transition: background-color $transition-time-action;
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
transition: bottom 1s;
|
||||
bottom: -0;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.followButton {
|
||||
bottom: 10vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './FollowButton.module.scss';
|
||||
|
||||
interface FollowButtonProps {
|
||||
isVisible: boolean;
|
||||
onClickHandler: () => void;
|
||||
}
|
||||
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const { isVisible, onClickHandler } = props;
|
||||
|
||||
const classes = cx([style.followButton, !isVisible && style.hidden]);
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler} type='button'>
|
||||
<IoLocate />
|
||||
Follow
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.OpBlock {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
|
||||
import style from './OpBlock.module.scss';
|
||||
|
||||
interface OpBlockProps {
|
||||
data: OntimeBlock;
|
||||
}
|
||||
|
||||
export default function OpBlock({ data }: OpBlockProps) {
|
||||
return <div className={style.OpBlock}>{data.title}</div>;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.scheduledEvent {
|
||||
@include event-block();
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
|
||||
.runningTimer {
|
||||
background-color: $green-700;
|
||||
}
|
||||
|
||||
.cue {
|
||||
font-size: 0.75rem;
|
||||
background-color: $gray-1050; // to override inline
|
||||
padding: 0.2rem;
|
||||
min-width: 4em; // we do not want these to resize
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
|
||||
&:after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.65rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.time > div {
|
||||
margin-bottom: -4px; /* Adjust the value as needed */
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { OntimeEvent, UserFields } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OpEvent.module.scss';
|
||||
|
||||
type OpEventProps = {
|
||||
data: OntimeEvent;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: keyof UserFields;
|
||||
};
|
||||
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
export default function OpEvent({ data, index, isSelected, subscribed }: OpEventProps) {
|
||||
const start = formatTime(data.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(data.timeEnd, { format: 'hh:mm' });
|
||||
|
||||
const cueColours = data.colour && getAccessibleColour(data.colour);
|
||||
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
|
||||
|
||||
// @arihanv when selected, the whole row should become green
|
||||
return (
|
||||
<div className={`${isSelected ? style.runningTimer : undefined}`}>
|
||||
<div className={style.scheduledEvent}>
|
||||
<div className={style.cue} style={{ ...cueColours }}>
|
||||
{index}
|
||||
</div>
|
||||
<div className={style.event}>
|
||||
<div className={style.title}>
|
||||
{data.title} - {data.subtitle}
|
||||
</div>
|
||||
<div className={style.time}>
|
||||
<div>
|
||||
{start} - {end}
|
||||
</div>
|
||||
<div className={style.indicator}>
|
||||
<DelayIndicator delayValue={data.delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/** @arihanv we likely want to animate the height of the fields div */}
|
||||
<div className={style.fields}>{subscribedData}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.block {
|
||||
@include block();
|
||||
background-color: $gray-1350;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import style from './OperatorBlock.module.scss';
|
||||
|
||||
interface OperatorBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function OperatorBlock({ title }: OperatorBlockProps) {
|
||||
return <div className={style.block}>{title}</div>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@import '../Operator.module.scss';
|
||||
|
||||
.event {
|
||||
@include block();
|
||||
background-color: $gray-1300;
|
||||
|
||||
&.subscribed {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background-color: $red-700;
|
||||
}
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.02px;
|
||||
}
|
||||
|
||||
.cue {
|
||||
font-size: 1rem;
|
||||
background-color: $gray-1050; // to override inline
|
||||
padding: 0 0.5rem;
|
||||
text-align: center;
|
||||
border-radius: $component-border-radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: calc(1rem - 1px);
|
||||
|
||||
.runtime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: $orange-500;
|
||||
|
||||
letter-spacing: 0.02px;
|
||||
height: fit-content;
|
||||
|
||||
transition-property: height;
|
||||
transition-duration: $transition-time-feedback;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.times {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { OntimeEvent, UserFields } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
interface OperatorEventProps {
|
||||
data: OntimeEvent;
|
||||
cue: string;
|
||||
isSelected: boolean;
|
||||
subscribed: keyof UserFields | null;
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
function RollingTime() {
|
||||
const timer = useTimer();
|
||||
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||
}
|
||||
|
||||
export default function OperatorEvent(props: OperatorEventProps) {
|
||||
const { data, cue, isSelected, subscribed, showSeconds } = props;
|
||||
|
||||
const start = formatTime(data.timeStart, { showSeconds });
|
||||
const end = formatTime(data.timeEnd, { showSeconds });
|
||||
|
||||
const cueColours = data.colour && getAccessibleColour(data.colour);
|
||||
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
isSelected ? style.running : null,
|
||||
subscribedData ? style.subscribed : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses}>
|
||||
<div className={style.titles}>
|
||||
<span className={style.title}>
|
||||
{data.title} - {data.subtitle}
|
||||
</span>
|
||||
<span className={style.cue} style={{ ...cueColours }}>
|
||||
{cue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style.times}>
|
||||
<span className={style.schedule}>
|
||||
{start} - {end}
|
||||
</span>
|
||||
<span className={style.runtime}>
|
||||
<DelayIndicator delayValue={data.delay} />
|
||||
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style.fields}>{subscribedData}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
|
||||
export function getRundownEnd(rundown: OntimeRundown): OntimeEvent | null {
|
||||
if (rundown.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = rundown.length - 1; i > 0; i--) {
|
||||
if (rundown[i].type === SupportedEvent.Event) {
|
||||
return rundown[i] as OntimeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../../../src/theme/ontimeColours' as *;
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.statusBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid $white-10;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: gray;
|
||||
}
|
||||
+8
-15
@@ -1,12 +1,13 @@
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import styles from './TimeBlock.module.scss';
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
export default function TimeBlock({
|
||||
export default function StatusBar({
|
||||
playback,
|
||||
lastEvent,
|
||||
selectedEventId,
|
||||
@@ -23,27 +24,19 @@ export default function TimeBlock({
|
||||
}
|
||||
|
||||
const timeEnd = lastEvent.id === selectedEventId ? timer.expectedFinish : lastEvent.timeEnd;
|
||||
return formatTime(timeEnd, { showSeconds: true, format: 'hh:mm:ss' });
|
||||
return millisToString(timeEnd);
|
||||
};
|
||||
|
||||
// TODO: format should be user defined
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
|
||||
const runningTime = formatTime(timer.current, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
|
||||
const elapsedTime = formatTime(timer.elapsed, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss',
|
||||
});
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
return (
|
||||
<div className={styles.TimeBlock}>
|
||||
<div className={styles.statusBar}>
|
||||
<PlaybackIcon state={playback} />
|
||||
<div className={styles.clock}>
|
||||
<div className={styles.column}>
|
||||
@@ -1,25 +0,0 @@
|
||||
.TimeBlock {
|
||||
padding: 0.65rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px
|
||||
}
|
||||
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: gray;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
|
||||
$block-clearance: 0.5rem;
|
||||
$block-border-radius: 0.5rem;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1200;
|
||||
$block-bg: $gray-1250;
|
||||
$block-bg2: $gray-1050; // for delay and blocks
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 2.5rem;
|
||||
|
||||
@@ -24,8 +24,8 @@ $gray-900: #4c4c4c;
|
||||
$gray-1000: #404040;
|
||||
$gray-1050: #303030;
|
||||
$gray-1100: #2d2d2d;
|
||||
$gray-1250: #262626;
|
||||
$gray-1200: #202020;
|
||||
$gray-1200: #262626;
|
||||
$gray-1250: #202020;
|
||||
$gray-1300: #1a1a1a;
|
||||
$gray-1350: #101010;
|
||||
$pure-white: #fff;
|
||||
|
||||
@@ -36,6 +36,7 @@ $bg-container-onlight: $gray-100;
|
||||
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
|
||||
$modal-note-color: $gray-700;
|
||||
|
||||
@@ -54,6 +55,9 @@ $section-white: $ui-white;
|
||||
$inner-section-text-size: calc(1rem - 2px);
|
||||
$text-body-size: calc(1rem - 1px);
|
||||
|
||||
// media queries
|
||||
$min-tablet: 500px;
|
||||
|
||||
.blink {
|
||||
animation: blink $blinking-time linear infinite;
|
||||
}
|
||||
@@ -62,4 +66,4 @@ $text-body-size: calc(1rem - 1px);
|
||||
50% {
|
||||
opacity: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ontimeButtonOutlined = {
|
||||
},
|
||||
_active: {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
borderColor: '#202020', // $gray-12000
|
||||
borderColor: '#202020', // $gray-1250
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export const ontimeButtonSubtle = {
|
||||
},
|
||||
_active: {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
borderColor: '#202020', // $gray-12000
|
||||
borderColor: '#202020', // $gray-1250
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const ontimeBlockRadio = {
|
||||
control: {
|
||||
borderColor: '#262626', // $gray-1250
|
||||
backgroundColor: '#262626', // $gray-1250
|
||||
borderColor: '#262626', // $gray-1200
|
||||
backgroundColor: '#262626', // $gray-1200
|
||||
_checked: {
|
||||
borderColor: '#262626', // $gray-1250
|
||||
borderColor: '#262626', // $gray-1200
|
||||
color: '#3182ce', // $action-blue
|
||||
backgroundColor: '#3182ce', // $action-blue
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const commonStyles = {
|
||||
fontWeight: '400',
|
||||
backgroundColor: '#262626', // $gray-1250
|
||||
backgroundColor: '#262626', // $gray-1200
|
||||
color: '#e2e2e2', // $gray-200
|
||||
border: '1px solid transparent',
|
||||
_hover: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// runtime utils
|
||||
export { getFirst, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
|
||||
export { getFirst, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
|
||||
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
||||
|
||||
// rundown utils
|
||||
|
||||
@@ -26,6 +26,21 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLastEvent(rundown: OntimeRundown): OntimeEvent | null {
|
||||
if (rundown.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = rundown.length - 1; i > 0; i--) {
|
||||
const event = rundown.at(i);
|
||||
if (isOntimeEvent(event)) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets next event in rundown, if it exists
|
||||
* @param {OntimeRundownEntry[]} rundown
|
||||
|
||||
Reference in New Issue
Block a user