mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
feat: operator view
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
import style from './Empty.module.scss';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@use '../../../theme/ontimeColours' as *;
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
.drawerContent {
|
.drawerContent {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerHeader {
|
.drawerHeader {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UserFields } from 'ontime-types';
|
||||||
|
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
export const TIME_FORMAT_OPTION: ParamField = {
|
export const TIME_FORMAT_OPTION: ParamField = {
|
||||||
@@ -202,30 +204,38 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const OPERATOR_OPTIONS: ParamField[] = [
|
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
||||||
TIME_FORMAT_OPTION,
|
return [
|
||||||
{
|
TIME_FORMAT_OPTION,
|
||||||
id: 'hidepast',
|
{
|
||||||
title: 'Hide Past Events',
|
id: 'showseconds',
|
||||||
description: 'Whether to events that have passed',
|
title: 'Show seconds',
|
||||||
type: 'boolean',
|
description: 'Schedule shows hh:mm:ss',
|
||||||
},
|
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',
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
];
|
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 = {
|
type ColourCombination = {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Selects text colour to maintain accessible contrast
|
* @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}`);
|
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
|
* @description Creates a list of classnames from array of css module conditions
|
||||||
* @param classNames - css modules objects
|
* @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 {
|
.eventEditor {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background-color: $bg-container-l2;
|
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;
|
border-top: 1px solid $white-20;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-left: 1px solid $white-10;
|
border-left: 1px solid $white-10;
|
||||||
border-radius: 0 8px 0 0;
|
border-radius: 0 8px 0 0;
|
||||||
|
|||||||
@@ -4,29 +4,26 @@
|
|||||||
.operatorContainer {
|
.operatorContainer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
font-size: 0.8rem;
|
|
||||||
display: flex;
|
color: $ui-white;
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.operatorEvents {
|
.operatorEvents {
|
||||||
flex: 1;
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
|
||||||
|
|
||||||
@mixin event-block() {
|
|
||||||
height: 40px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 2px;
|
||||||
margin: 0.1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin block() {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
|
||||||
.block {
|
// tablet
|
||||||
@include event-block();
|
@media (min-width: $min-tablet) {
|
||||||
background-color: $gray-1350;
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { SupportedEvent, UserFields } from 'ontime-types';
|
import { SupportedEvent, UserFields } from 'ontime-types';
|
||||||
|
import { getLastEvent } from 'ontime-utils';
|
||||||
|
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
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 ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useOperator } from '../../common/hooks/useSocket';
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
|
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||||
|
|
||||||
import FocusBlock from './focus-block/focus-block';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
import OpBlock from './op-block/OpBlock';
|
import OperatorBlock from './operator-block/OperatorBlock';
|
||||||
import OpEvent from './op-event/OpEvent';
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
import TimeBlock from './time-block/TimeBlock';
|
import StatusBar from './status-bar/StatusBar';
|
||||||
import { getRundownEnd } from './operator.utils';
|
|
||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||||
|
|
||||||
export default function Operator() {
|
export default function Operator() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
|
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||||
const featureData = useOperator();
|
const featureData = useOperator();
|
||||||
const [showChild, setShowChild] = useState(false);
|
const [showChild, setShowChild] = useState(false);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Set window title
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'ontime - Operator';
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleScroll = (event: UIEvent<HTMLElement>) => {
|
const handleScroll = (event: UIEvent<HTMLElement>) => {
|
||||||
const scrollThreshold = 50;
|
const scrollThreshold = 50;
|
||||||
const scrollPosition = (event.target as HTMLElement).scrollTop;
|
const scrollPosition = (event.target as HTMLElement).scrollTop;
|
||||||
@@ -29,49 +38,45 @@ export default function Operator() {
|
|||||||
setShowChild(scrollPosition > scrollThreshold);
|
setShowChild(scrollPosition > scrollThreshold);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data || status === 'loading') {
|
if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') {
|
||||||
return <>loading</>;
|
return <Empty text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get fields which the user subscribed to
|
||||||
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
const lastEvent = getRundownEnd(data);
|
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||||
let eventIndex = 0;
|
const lastEvent = getLastEvent(data);
|
||||||
|
|
||||||
|
const operatorOptions = getOperatorOptions(userFields);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer}>
|
<div className={style.operatorContainer}>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={OPERATOR_OPTIONS} />
|
<ViewParamsEditor paramFields={operatorOptions} />
|
||||||
|
|
||||||
<div className={style.operatorEvents} onScroll={handleScroll}>
|
<div className={style.operatorEvents} onScroll={handleScroll}>
|
||||||
{data.map((entry) => {
|
{data.map((entry) => {
|
||||||
if (entry.type === SupportedEvent.Event) {
|
if (entry.type === SupportedEvent.Event) {
|
||||||
eventIndex += 1;
|
|
||||||
return (
|
return (
|
||||||
<OpEvent
|
<OperatorEvent
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
index={eventIndex}
|
cue={entry.cue}
|
||||||
data={entry}
|
data={entry}
|
||||||
isSelected={featureData.selectedEventId === entry.id}
|
isSelected={featureData.selectedEventId === entry.id}
|
||||||
subscribed={subscribe || undefined}
|
subscribed={subscribe}
|
||||||
|
showSeconds={showSeconds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === SupportedEvent.Block) {
|
if (entry.type === SupportedEvent.Block) {
|
||||||
return (
|
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||||
// @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 null;
|
return null;
|
||||||
})}
|
})}
|
||||||
{showChild && <FocusBlock />}
|
|
||||||
</div>
|
</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>
|
</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 { OntimeEvent, Playback } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
import { useTimer } from '../../../common/hooks/useSocket';
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
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,
|
playback,
|
||||||
lastEvent,
|
lastEvent,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
@@ -23,27 +24,19 @@ export default function TimeBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeEnd = lastEvent.id === selectedEventId ? timer.expectedFinish : lastEvent.timeEnd;
|
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, {
|
const timeNow = formatTime(timer.clock, {
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
format: 'hh:mm:ss',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const runningTime = formatTime(timer.current, {
|
const runningTime = millisToString(timer.current);
|
||||||
showSeconds: true,
|
const elapsedTime = millisToString(timer.elapsed);
|
||||||
format: 'hh:mm:ss',
|
|
||||||
});
|
|
||||||
|
|
||||||
const elapsedTime = formatTime(timer.elapsed, {
|
|
||||||
showSeconds: true,
|
|
||||||
format: 'hh:mm:ss',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.TimeBlock}>
|
<div className={styles.statusBar}>
|
||||||
<PlaybackIcon state={playback} />
|
<PlaybackIcon state={playback} />
|
||||||
<div className={styles.clock}>
|
<div className={styles.clock}>
|
||||||
<div className={styles.column}>
|
<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-clearance: 0.5rem;
|
||||||
$block-border-radius: 0.5rem;
|
$block-border-radius: 0.5rem;
|
||||||
$block-text-color: $gray-50;
|
$block-text-color: $gray-50;
|
||||||
$block-bg: $gray-1200;
|
$block-bg: $gray-1250;
|
||||||
$block-bg2: $gray-1050; // for delay and blocks
|
$block-bg2: $gray-1050; // for delay and blocks
|
||||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||||
$secondary-block-height: 2.5rem;
|
$secondary-block-height: 2.5rem;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ $gray-900: #4c4c4c;
|
|||||||
$gray-1000: #404040;
|
$gray-1000: #404040;
|
||||||
$gray-1050: #303030;
|
$gray-1050: #303030;
|
||||||
$gray-1100: #2d2d2d;
|
$gray-1100: #2d2d2d;
|
||||||
$gray-1250: #262626;
|
$gray-1200: #262626;
|
||||||
$gray-1200: #202020;
|
$gray-1250: #202020;
|
||||||
$gray-1300: #1a1a1a;
|
$gray-1300: #1a1a1a;
|
||||||
$gray-1350: #101010;
|
$gray-1350: #101010;
|
||||||
$pure-white: #fff;
|
$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-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-l2: 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;
|
$modal-note-color: $gray-700;
|
||||||
|
|
||||||
@@ -54,6 +55,9 @@ $section-white: $ui-white;
|
|||||||
$inner-section-text-size: calc(1rem - 2px);
|
$inner-section-text-size: calc(1rem - 2px);
|
||||||
$text-body-size: calc(1rem - 1px);
|
$text-body-size: calc(1rem - 1px);
|
||||||
|
|
||||||
|
// media queries
|
||||||
|
$min-tablet: 500px;
|
||||||
|
|
||||||
.blink {
|
.blink {
|
||||||
animation: blink $blinking-time linear infinite;
|
animation: blink $blinking-time linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ontimeButtonOutlined = {
|
|||||||
},
|
},
|
||||||
_active: {
|
_active: {
|
||||||
backgroundColor: '#2d2d2d', // $gray-1100
|
backgroundColor: '#2d2d2d', // $gray-1100
|
||||||
borderColor: '#202020', // $gray-12000
|
borderColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const ontimeButtonSubtle = {
|
|||||||
},
|
},
|
||||||
_active: {
|
_active: {
|
||||||
backgroundColor: '#2d2d2d', // $gray-1100
|
backgroundColor: '#2d2d2d', // $gray-1100
|
||||||
borderColor: '#202020', // $gray-12000
|
borderColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const ontimeBlockRadio = {
|
export const ontimeBlockRadio = {
|
||||||
control: {
|
control: {
|
||||||
borderColor: '#262626', // $gray-1250
|
borderColor: '#262626', // $gray-1200
|
||||||
backgroundColor: '#262626', // $gray-1250
|
backgroundColor: '#262626', // $gray-1200
|
||||||
_checked: {
|
_checked: {
|
||||||
borderColor: '#262626', // $gray-1250
|
borderColor: '#262626', // $gray-1200
|
||||||
color: '#3182ce', // $action-blue
|
color: '#3182ce', // $action-blue
|
||||||
backgroundColor: '#3182ce', // $action-blue
|
backgroundColor: '#3182ce', // $action-blue
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const commonStyles = {
|
const commonStyles = {
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
backgroundColor: '#262626', // $gray-1250
|
backgroundColor: '#262626', // $gray-1200
|
||||||
color: '#e2e2e2', // $gray-200
|
color: '#e2e2e2', // $gray-200
|
||||||
border: '1px solid transparent',
|
border: '1px solid transparent',
|
||||||
_hover: {
|
_hover: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// runtime utils
|
// 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';
|
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
||||||
|
|
||||||
// rundown utils
|
// rundown utils
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]) {
|
|||||||
return null;
|
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
|
* Gets next event in rundown, if it exists
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
* @param {OntimeRundownEntry[]} rundown
|
||||||
|
|||||||
Reference in New Issue
Block a user