chore: move files to new structure

This commit is contained in:
Carlos Valente
2024-12-10 14:31:50 +01:00
committed by Carlos Valente
parent fa7ec623f1
commit 6ffbf2af9d
27 changed files with 6 additions and 6 deletions
@@ -1,151 +0,0 @@
$table-font-size: calc(1rem - 2px);
$table-header-font-size: calc(1rem - 3px);
.cuesheetContainer {
grid-area: table;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
padding-bottom: 640px; // allow focus to reach last elements
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
tr {
display: flex;
}
th,
td {
margin: 1px;
font-weight: inherit;
font-size: inherit;
text-align: left;
position: relative;
@include ellipsis-overflow;
}
}
.tableHeader,
.eventRow {
.indexColumn {
min-width: 2rem;
text-align: right;
font-weight: 400;
position: sticky;
left: 0;
z-index: 1;
background-color: $gray-1300;
}
}
.tableHeader {
position: sticky;
top: -1px;
z-index: 10;
font-size: $table-header-font-size;
color: $gray-700;
}
th {
background-color: $gray-1300;
padding-left: 0.25rem;
&:hover {
.resizer {
width: 0.5rem;
}
}
}
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
background-color: $gray-1250;
border-radius: 2px;
padding: 0.25rem;
}
&.skip {
text-decoration: line-through;
opacity: $opacity-disabled !important; // fighting inline styles
}
}
.blockRow {
width: 100%;
background-color: $gray-1350;
font-size: 1rem;
height: 2.5rem;
td {
align-self: flex-end;
position: sticky;
left: 1rem;
padding: 0.25rem 0;
}
}
.delayRow {
width: 100%;
color: $ontime-delay-text;
td {
position: sticky;
left: 47.5%; // center of the screen, ish
padding: 0.5rem 0;
&:first-letter {
text-transform: uppercase;
}
}
}
.check {
font-size: 1.5rem;
margin: 0 auto;
}
.time {
display: flex;
gap: 0.5rem;
align-items: center;
> * {
@include ellipsis-overflow;
}
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
}
.resizer {
cursor: col-resize;
opacity: $opacity-disabled;
display: inline-block;
width: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
background-color: $action-blue;
user-select: none;
touch-action: none;
&:hover {
opacity: 1;
}
}
@@ -1,183 +0,0 @@
import { useCallback, useRef } from 'react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
} = useColumnManager(columns);
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
state: {
columnOrder,
columnVisibility,
columnSizing,
},
meta: {
handleUpdate,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const setAllVisible = () => {
table.toggleAllColumnsVisible(true);
};
const resetColumnResizing = () => {
setColumnSizing({});
};
const reorder = useCallback(
(fromId: string, toId: string) => {
// get index of from
const fromIndex = columnOrder.indexOf(fromId);
// get index of to
const toIndex = columnOrder.indexOf(toId);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
},
[columnOrder, saveColumnOrder],
);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0;
let isPast = Boolean(selectedId);
return (
<>
{showSettings && (
<CuesheetTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody>
{rowModel.rows.map((row) => {
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
isPast = false;
}
if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isPast && !showPrevious) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<EventRow
key={key}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
showIndexColumn={showIndexColumn}
>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
</table>
</div>
</>
);
}
@@ -1,15 +0,0 @@
.tableWrapper {
background-color: $ui-black;
width: 100%;
height: 100vh;
padding: 1rem 0.5rem;
display: grid;
grid-template-rows: 3rem auto 1fr;
grid-template-areas:
'overview'
'settings'
'table';
gap: 1rem;
color: $ui-white;
}
@@ -1,114 +0,0 @@
import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { useCuesheetSettings } from './store/CuesheetSettings';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
useWindowTitle('Cuesheet');
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = flatRundown[rowIndex];
if (!event || !isOntimeEvent(event)) {
return;
}
const previousValue = event.custom[accessor];
if (previousValue === payload) {
return;
}
// check if value is valid
// in anticipation to different types of event here
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
// submit
try {
await updateCustomField(event.id, accessor, cleanVal);
} catch (error) {
console.error(error);
}
},
[flatRundown, rundownStatus, updateCustomField],
);
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<CuesheetOverview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</CuesheetOverview>
<CuesheetProgress />
<Cuesheet
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
);
}
@@ -1,11 +0,0 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import CuesheetWrapper from './CuesheetWrapper';
export default function ProtectedCuesheet() {
return (
<ProtectRoute permission='operator'>
<CuesheetWrapper />
</ProtectRoute>
);
}
@@ -1,41 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`;
@@ -1,84 +0,0 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000;
const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('duration', testData3)).toBe('00:10:00');
});
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
const testTruthy = [1, true, 'x', 'test'];
const testFalsy = ['', null, undefined, false, 0];
testTruthy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('x');
});
});
testFalsy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('');
});
});
});
it('returns an empty string on undefined fields', () => {
expect(parseField('title')).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {
title: 'test title',
description: 'test description',
};
const tableData = [
{
title: 'test title 1',
timeStart: 0,
timeEnd: 0,
isPublic: 'x',
lighting: { value: 'test lighting' },
sound: { value: 'test sound' },
},
];
const customFields = {
lighting: { label: 'test' },
};
const table = makeTable(headerData, tableData, customFields);
expect(table).toMatchSnapshot();
});
});
describe('make CSV()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
"field
after newline,after comma
,after empty
"
`);
});
});
@@ -1,3 +0,0 @@
.progressOverride {
height: 1rem;
}
@@ -1,24 +0,0 @@
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useProgressData } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() {
const { data } = useViewSettings();
const { current, duration, timeWarning, timeDanger } = useProgressData();
return (
<MultiPartProgressBar
now={current}
complete={duration}
normalColor={data.normalColor}
warning={timeWarning}
warningColor={data.warningColor}
danger={timeDanger}
dangerColor={data.dangerColor}
className={styles.progressOverride}
ignoreCssOverride
/>
);
}
@@ -1,18 +0,0 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -1,89 +0,0 @@
import {
closestCorners,
DndContext,
DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
}
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
saveColumnOrder(active.id as string, over.id as string);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
let customStyles = {};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
}
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
@@ -1,22 +0,0 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Cuesheet.module.scss';
interface DelayRowProps {
duration: number;
}
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
export default memo(DelayRow);
@@ -1,55 +0,0 @@
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { getHotkeyHandler } from '@mantine/hooks';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
interface EditableCellProps {
value: string;
handleUpdate: (newValue: string) => void;
}
const EditableCell = (props: EditableCellProps) => {
const { value: initialValue, handleUpdate } = props;
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const ref = useRef<HTMLAreaElement>();
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
//TODO: maybe we can unify this with `useReactiveTextInput`
const onKeyDown = getHotkeyHandler([
['mod + Enter', () => ref.current?.blur()],
[
'Escape',
() => {
setValue(initialValue);
setTimeout(() => ref.current?.blur());
},
],
]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
inputref={ref}
onChange={onChange}
onBlur={onBlur}
rows={1}
onKeyDown={onKeyDown}
transition='none'
spellCheck={false}
style={{ padding: 0 }}
/>
);
};
export default memo(EditableCell);
@@ -1,67 +0,0 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Cuesheet.module.scss';
const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
showIndexColumn: boolean;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const textColour = getAccessibleColour(colour);
const bgColour = textColour.backgroundColor;
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{showIndexColumn && eventIndex}
</td>
{isVisible ? children : null}
</tr>
);
}
export default memo(EventRow);
@@ -1,44 +0,0 @@
import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
header: Header<OntimeRundownEntry, unknown>;
style: CSSProperties;
children: ReactNode;
}
export function SortableCell({ header, style, children }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
// build drag styles
const dragStyle = {
...style,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
};
return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}>
{children}
</div>
<div
{...{
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={styles.resizer}
/>
</th>
);
}
@@ -1,120 +0,0 @@
$label-colour: $gray-700;
$active-colour: $gray-500;
@mixin label {
font-size: $inner-section-text-size;
color: $label-colour;
text-align: center;
align-self: end;
}
@mixin time {
font-family: 'Open Sans Light', $ontime-font-family;
font-size: 2rem;
text-align: center;
}
.header {
grid-area: header;
display: grid;
width: 100%;
padding: 0.25rem 1rem;
height: max-content;
column-gap: 2rem;
grid-template-areas: 'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
}
.event {
grid-area: event;
justify-self: start;
.title {
font-size: 1.75rem;
}
.eventNow {
justify-self: start;
font-size: 1.5rem;
}
}
.playback {
grid-area: playback;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.playbackLabel {
@include label;
}
svg {
font-size: 2rem;
height: 3rem;
color: $label-colour;
}
}
.timer {
grid-area: timer;
.timerLabel {
@include label;
}
.value {
@include time;
}
}
.clock {
grid-area: clock;
.clockLabel {
@include label;
}
.value {
grid-area: clock;
@include time;
}
}
.headerActions {
grid-area: actions;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-colour;
height: 100%;
font-size: 1rem;
.actionIcon,
.actionText {
cursor: pointer;
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
.actionIcon {
font-size: 1.25rem;
}
}
@media (min-width: 1200px) {
// in large screens we want to space the buttons
.headerActions {
padding-left: 10vw;
}
}
@@ -1,70 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
numEvents: number;
titleNow: string | null;
};
}
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { data: project } = useProjectData();
const exportProject = () => {
if (project) {
handleExport(project);
}
};
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
featureData.numEvents ? featureData.numEvents : enDash
}`;
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || enDash}</div>
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span
onClick={() => toggleFollow()}
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionText} onClick={exportProject}>
Export CSV
</span>
</Tooltip>
</div>
</div>
);
}
@@ -1,23 +0,0 @@
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const { current } = useTimer();
const { clock } = useClock();
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<RunningTime className={style.value} value={current} hideLeadingZero />
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<ClockTime className={style.value} value={clock} />
</div>
</>
);
}
@@ -1,41 +0,0 @@
.tableSettings {
grid-area: settings;
padding: 1rem;
background-color: $ui-black;
display: flex;
font-size: $inner-section-text-size;
}
.leftPanel {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
}
.sectionTitle {
color: $gray-700;
text-transform: uppercase;
}
.options {
display: flex;
flex-wrap: wrap;
column-gap: 1rem;
row-gap: 0.25em;
}
.option {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rightPanel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 2rem;
}
@@ -1,106 +0,0 @@
import { memo, ReactNode } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import style from './CuesheetTableSettings.module.scss';
// reusable button styles
const buttonProps = {
size: 'sm',
variant: 'ontime-subtle',
};
interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
showIndexColumn,
toggleFollow,
showPrevious,
togglePreviousVisibility,
showDelayBlock,
hideSeconds,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
toggleSecondsVisibility,
} = useCuesheetSettings();
return (
<div className={style.tableSettings}>
<div className={style.leftPanel}>
<div className={style.sectionTitle}>Toggle column visibility</div>
<div className={style.options}>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox
variant='ontime-ondark'
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader as ReactNode}
</label>
);
})}
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
Show delayed times
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
Hide seconds
</label>
</div>
</div>
<div className={style.rightPanel}>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
</div>
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -1,126 +0,0 @@
import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import style from './Cuesheet.module.scss';
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
const cellValue = row.getValue();
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
const delayValue = (original as OntimeEvent)?.delay ?? 0;
return (
<span className={style.time}>
<DelayIndicator delayValue={delayValue} />
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
{delayValue !== 0 && showDelayedTimes && (
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
)}
</span>
);
}
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(row.index, column.id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
// events dont necessarily contain all custom fields
const initialValue = event.custom[column.id] ?? '';
return <EditableCell value={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
header: customFields[key].label,
meta: { colour: customFields[key].colour },
cell: MakeCustomField,
size: 250,
}));
return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
header: 'Public',
cell: makePublic,
size: 45,
},
{
accessorKey: 'timeStart',
id: 'timeStart',
header: 'Start',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'timeEnd',
id: 'timeEnd',
header: 'End',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'duration',
id: 'duration',
header: 'Duration',
cell: MakeDuration,
size: 75,
},
{
accessorKey: 'title',
id: 'title',
header: 'Title',
cell: (row) => row.getValue(),
size: 250,
},
{
accessorKey: 'note',
id: 'note',
header: 'Note',
cell: (row) => row.getValue(),
size: 250,
},
...dynamicCustomFields,
];
}
@@ -1,115 +0,0 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEntryCommonKeys,
OntimeRundown,
ProjectData,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
* @param {*} data
* @return {string}
*/
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber);
}
if (field === 'isPublic' || field === 'skip') {
return data ? 'x' : '';
}
return String(data ?? '');
};
/**
* @description Creates an array of arrays usable by xlsx for export
* @param {ProjectData} headerData
* @param {OntimeRundown} rundown
* @param {CustomFields} customFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => {
// create metadata header row
const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
if (headerData.description) data.push([`Project description: ${headerData.description}`]);
const customFieldKeys = Object.keys(customFields).map((key) => `custom-${key}`);
const customFieldLabels = Object.keys(customFields);
// we chose not to expose internals of the application
const fieldOrder: CsvHeaderKey[] = [
'timeStart',
'timeEnd',
'duration',
'id',
'colour',
'cue',
'title',
'note',
'isPublic',
'skip',
...customFieldKeys,
];
const fieldTitles = [
'Time Start',
'Time End',
'Duration',
'ID',
'Colour',
'Cue',
'Title',
'Note',
'Is Public? (x)',
'Skip?',
...customFieldLabels,
];
// add header row to data
data.push(fieldTitles);
rundown.forEach((entry) => {
if (isOntimeDelay(entry)) return;
const row: string[] = [];
fieldOrder.forEach((field) => {
if (isOntimeEvent(entry)) {
// for custom fields, we need to extract the value from the custom object
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
const value = entry.custom[fieldLabel];
row.push(parseField(fieldLabel, value));
} else {
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
}
return;
}
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
});
data.push(row);
});
return data;
};
/**
* @description Converts an array of arrays to a csv file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export const makeCSV = (arrayOfArrays: string[][]): string => {
const stringifiedData = stringify(arrayOfArrays);
return stringifiedData;
};
@@ -1,19 +0,0 @@
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
/**
* @description set default column order
*/
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'cue',
'timeStart',
'timeEnd',
'duration',
'title',
'note',
];
/**
* @description set default hidden columns
*/
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
@@ -1,85 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean;
showPrevious: boolean;
showDelayBlock: boolean;
showDelayedTimes: boolean;
hideSeconds: boolean;
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void;
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
toggleSecondsVisibility: (newValue?: boolean) => void;
}
function toggle(oldValue: boolean, value?: boolean) {
if (typeof value === 'undefined') {
return !oldValue;
}
return value;
}
enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed',
Seconds = 'ontime-cuesheet-hide-sceconds',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
toggleFollow: (newValue?: boolean) =>
set((state) => {
const followSelected = toggle(state.followSelected, newValue);
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected };
}),
toggleIndexColumn: (newValue?: boolean) =>
set((state) => {
const showIndexColumn = toggle(state.showIndexColumn, newValue);
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
return { showIndexColumn };
}),
togglePreviousVisibility: (newValue?: boolean) =>
set((state) => {
const showPrevious = toggle(state.showPrevious, newValue);
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
return { showPrevious };
}),
toggleDelayVisibility: (newValue?: boolean) =>
set((state) => {
const showDelayBlock = toggle(state.showDelayBlock, newValue);
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
return { showDelayBlock };
}),
toggleDelayedTimes: (newValue?: boolean) =>
set((state) => {
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
return { showDelayedTimes };
}),
toggleSecondsVisibility: (newValue?: boolean) =>
set((state) => {
const hideSeconds = toggle(state.hideSeconds, newValue);
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
return { hideSeconds };
}),
}));
@@ -1,46 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order',
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
// if the columns change, we update the dataset
useEffect(() => {
let shouldReplace = false;
const newColumns: string[] = [];
// iterate through columns to see if there are new ids
columns.forEach((column) => {
const columnnId = column.id as string;
if (!shouldReplace && !columnOrder.includes(columnnId)) {
shouldReplace = true;
}
newColumns.push(columnnId);
});
if (shouldReplace) {
saveColumnOrder(newColumns);
}
}, [columnOrder, columns, saveColumnOrder]);
const resetColumnOrder = useCallback(() => {
saveColumnOrder(columns.map((col) => col.id as string));
}, [columns, saveColumnOrder]);
return {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
};
}