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,
+ };
+}