diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss new file mode 100644 index 000000000..0ea878c77 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -0,0 +1,154 @@ +$table-font-size: 1rem; +$table-header-font-size: calc(1rem - 2px); + +.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: 3em; // allow for 3-digit numbers + text-align: right; + font-weight: 400; + font-size: $table-header-font-size; + position: sticky; + left: 0; + z-index: 1; + background-color: $gray-1300; + } + .actionColumn { + width: 2rem; + } +} + +.tableHeader { + position: sticky; + top: 0px; + z-index: 10; + background-color: $ui-black; + font-size: $table-header-font-size; + color: $label-gray;} + +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; + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx new file mode 100644 index 000000000..1ef44ff37 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; + +import { useCurrentBlockId } from '../../../../common/hooks/useSocket'; + +import style from '../CuesheetTable.module.scss'; + +interface BlockRowProps { + hidePast: boolean; + title: string; +} + +function BlockRow(props: BlockRowProps) { + const { hidePast, title } = props; + const { currentBlockId } = useCurrentBlockId(); + + if (hidePast && !currentBlockId) { + return null; + } + + return ( + + {title} + + ); +} + +export default memo(BlockRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx new file mode 100644 index 000000000..adc4ac0c8 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx @@ -0,0 +1,52 @@ +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 '../CuesheetTable.module.scss'; + +interface CuesheetHeaderProps { + headerGroups: HeaderGroup[]; + showIndexColumn: boolean; +} + +export default function CuesheetHeader(props: CuesheetHeaderProps) { + const { headerGroups, showIndexColumn } = props; + + return ( + + {headerGroups.map((headerGroup) => { + const key = headerGroup.id; + + return ( + + {showIndexColumn && '#'} + + + {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 ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + + ); + })} + + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx new file mode 100644 index 000000000..d8aaa57cf --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; + +import { millisToDelayString } from '../../../../common/utils/dateConfig'; + +import style from '../CuesheetTable.module.scss'; + +interface DelayRowProps { + duration: number; +} + +function DelayRow(props: DelayRowProps) { + const { duration } = props; + const delayTime = millisToDelayString(duration, 'expanded'); + + return ( + + {delayTime} + + ); +} + +export default memo(DelayRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx new file mode 100644 index 000000000..f5b504ab1 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -0,0 +1,65 @@ +import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react'; + +import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; + +import style from '../CuesheetTable.module.scss'; + +interface EventRowProps { + eventIndex: number; + showIndexColumn: boolean; + isPast?: boolean; + selectedRef?: MutableRefObject; + skip?: boolean; + colour?: string; +} + +function EventRow(props: PropsWithChildren) { + const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props; + const ownRef = useRef(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 ( + + + {showIndexColumn && eventIndex} + + {isVisible ? children : null} + + ); +} + +export default memo(EventRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx new file mode 100644 index 000000000..2a2f6b0f6 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx @@ -0,0 +1,37 @@ +import { memo, useCallback, useRef } from 'react'; + +import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea'; +import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; + +interface MultiLineCellProps { + initialValue: string; + handleUpdate: (newValue: string) => void; +} + +const MultiLineCell = (props: MultiLineCellProps) => { + const { initialValue, handleUpdate } = props; + const ref = useRef(null); + const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); + + const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnCtrlEnter: true, + }); + + return ( + + ); +}; + +export default memo(MultiLineCell); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx new file mode 100644 index 000000000..f8216d4a8 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx @@ -0,0 +1,35 @@ +import { memo, useCallback, useRef } from 'react'; +import { Input } from '@chakra-ui/react'; + +import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; + +interface SingleLineCellProps { + initialValue: string; + handleUpdate: (newValue: string) => void; +} + +const SingleLineCell = (props: SingleLineCellProps) => { + const { initialValue, handleUpdate } = props; + const ref = useRef(null); + const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); + + const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnCtrlEnter: true, + }); + + return ( + + ); +}; + +export default memo(SingleLineCell); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx new file mode 100644 index 000000000..30912ea44 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -0,0 +1,44 @@ +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 '../CuesheetTable.module.scss'; + +interface SortableCellProps { + header: Header; + 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 ( + +
+ {children} +
+
+ + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx new file mode 100644 index 000000000..fc2c24a24 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -0,0 +1,152 @@ +import { useCallback } from 'react'; +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 '../../../../features/viewers/common/running-time/RunningTime'; +import { useCuesheetOptions } from '../../cuesheet.options'; + +import MultiLineCell from './MultiLineCell'; +import SingleLineCell from './SingleLineCell'; + +import style from '../CuesheetTable.module.scss'; + +function MakeTimer({ getValue, row: { original } }: CellContext) { + const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); + const cellValue = (getValue() as number | null) ?? 0; + const delayValue = (original as OntimeEvent)?.delay ?? 0; + + return ( + + + + {delayValue !== 0 && showDelayedTimes && ( + + )} + + ); +} + +function MakeDuration({ getValue }: CellContext) { + const { hideTableSeconds } = useCuesheetOptions(); + const cellValue = (getValue() as number | null) ?? 0; + + return ; +} + +function MakeMultiLineField({ row, column, table }: CellContext) { + 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; + } + + const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; + + return ; +} + +function MakeSingleLineField({ row, column, table }: CellContext) { + 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; + } + + const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; + + return ; +} + +function MakeCustomField({ row, column, table }: CellContext) { + const update = useCallback( + (newValue: string) => { + // @ts-expect-error -- we inject this into react-table + table.options.meta?.handleUpdateCustom(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; + } + + const initialValue = event.custom[column.id] ?? ''; + + return ; +} + +export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { + 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: '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: MakeSingleLineField, + size: 250, + }, + { + accessorKey: 'note', + id: 'note', + header: 'Note', + cell: MakeMultiLineField, + size: 250, + }, + ...dynamicCustomFields, + ]; +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss new file mode 100644 index 000000000..4ef0c2da7 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss @@ -0,0 +1,29 @@ +.tableSettings { + grid-area: settings; + padding-inline: 0.5rem; + display: flex; + gap: 5rem; + font-size: $inner-section-text-size; + + @media (max-width: $small-screen) { + gap: 1rem; + } +} + +.sectionTitle { + text-transform: uppercase; +} + +.row { + display: flex; + flex-wrap: wrap; + column-gap: 1rem; + row-gap: 0.25em; +} + +.option { + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx new file mode 100644 index 000000000..3ab8b6d18 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -0,0 +1,65 @@ +import { memo, ReactNode } from 'react'; +import { Button, Checkbox } from '@chakra-ui/react'; +import { Column } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +import * as Editor from '../../../../features/editors/editor-utils/EditorUtils'; + +import style from './CuesheetTableSettings.module.scss'; + +// reusable button styles +const buttonProps = { + size: 'xs', + variant: 'ontime-subtle', +}; + +interface CuesheetTableSettingsProps { + columns: Column[]; + handleResetResizing: () => void; + handleResetReordering: () => void; + handleClearToggles: () => void; +} + +function CuesheetTableSettings(props: CuesheetTableSettingsProps) { + const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props; + + return ( +
+
+ Toggle column visibility +
+ {columns.map((column) => { + const columnHeader = column.columnDef.header; + const visible = column.getIsVisible(); + return ( + + ); + })} +
+
+
+ Reset Options +
+ + + +
+
+
+ ); +} + +export default memo(CuesheetTableSettings); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx new file mode 100644 index 000000000..7e8e33aaa --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx @@ -0,0 +1,46 @@ +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[]) { + const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); + const [columnOrder, saveColumnOrder] = useLocalStorage({ + 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, + }; +}