mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
refactor: cuesheet design review
fix: parsing of custom fields for blocks refactor: extract cuesheet settings refactor: cuesheet actions refactor: improve cuesheet performance on resizing
This commit is contained in:
committed by
Carlos Valente
parent
0726c04f20
commit
8ad260d28a
@@ -6,12 +6,11 @@
|
||||
padding-block: 0.5rem 1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 3rem auto auto 1fr;
|
||||
grid-template-rows: 3.5rem auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'overview'
|
||||
'progress'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,93 +1,50 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
||||
import { Modal, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||
import { useMemo } from 'react';
|
||||
import { IoApps } from 'react-icons/io5';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { CuesheetOverview } from '../../features/overview/Overview';
|
||||
import CuesheetEventEditor from '../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { cuesheetOptions } from './cuesheet.options';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
|
||||
const { isViewLocked } = useViewEditor({ isLockable: true });
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const [isEventEditorOpen, eventEditorHandler] = useDisclosure();
|
||||
const [eventId, setEventId] = useState<string | null>(null);
|
||||
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
/**
|
||||
* Handles setting the edit modal target and visibility
|
||||
*/
|
||||
const setShowModal = useCallback(
|
||||
(eventId: string | null) => {
|
||||
if (eventId) {
|
||||
setEventId(eventId);
|
||||
eventEditorHandler.open();
|
||||
} else {
|
||||
setEventId(null);
|
||||
eventEditorHandler.close();
|
||||
}
|
||||
},
|
||||
[eventEditorHandler],
|
||||
);
|
||||
|
||||
if (!customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending') {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isEventEditorOpen} onClose={eventEditorHandler.close} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(640px, 40vw)' padding='1rem'>
|
||||
<CuesheetEventEditor eventId={eventId!} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<CuesheetEditModal />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
||||
<CuesheetOverview>
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={menuHandler.open}
|
||||
disabled={isViewLocked}
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={showEditFormDrawer}
|
||||
disabled={isViewLocked}
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</IconButton>
|
||||
{!isViewLocked && (
|
||||
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetDnd columns={columns}>
|
||||
<CuesheetTable data={flatRundown} columns={columns} showModal={setShowModal} />
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
|
||||
</CuesheetDnd>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
|
||||
import { useCuesheetEditModal } from './useCuesheetEditModal';
|
||||
|
||||
export default memo(CuesheetEditModal);
|
||||
function CuesheetEditModal() {
|
||||
const entryId = useCuesheetEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useCuesheetEditModal((state) => state.clearSelection);
|
||||
|
||||
if (entryId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={closeModal}
|
||||
title='Edit entry'
|
||||
showCloseButton
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EntryId } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SelectedEntryState {
|
||||
selectedEntryId: EntryId | null;
|
||||
setEditableEntry: (entryId: EntryId) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export const useCuesheetEditModal = create<SelectedEntryState>((set) => ({
|
||||
selectedEntryId: null,
|
||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||
clearSelection: () => set({ selectedEntryId: null }),
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
.progressOverride {
|
||||
margin-top: 0.5rem;
|
||||
height: 1rem;
|
||||
grid-area: progress;
|
||||
}
|
||||
|
||||
@@ -8,25 +8,41 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 640px; // allow focus to reach last elements
|
||||
padding-bottom: 70vh; // allow focus to reach last elements
|
||||
}
|
||||
|
||||
.cuesheet {
|
||||
font-size: $table-font-size;
|
||||
font-weight: 400;
|
||||
color: $ui-white;
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: $gray-1300;
|
||||
padding-left: 0.25rem;
|
||||
font-weight: 400;
|
||||
|
||||
&:hover {
|
||||
.resizer {
|
||||
width: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 4px; // compensate left border
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
margin: 1px;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
@include ellipsis-overflow;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
th:focus,
|
||||
@@ -35,102 +51,44 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader,
|
||||
.eventRow {
|
||||
.actionColumn {
|
||||
width: calc(2rem + 0.5rem); // sm button size (--chakra-sizes-8) + 2 * padding
|
||||
background-color: transparent;
|
||||
}
|
||||
.actionColumn {
|
||||
padding-inline: 0.5rem;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
line-height: 2rem; // match input height
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
.indexColumn {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: end;
|
||||
|
||||
min-width: 3em; // allow for 3-digit numbers
|
||||
font-size: $table-header-font-size;
|
||||
background-color: $gray-1300; // will be overridden inline
|
||||
}
|
||||
min-width: 3em; // allow for 3-digit numbers
|
||||
font-size: $table-header-font-size;
|
||||
line-height: 2rem; // match input height
|
||||
background-color: $gray-1300; // will be overridden inline
|
||||
font-weight: 600;;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
line-height: 2rem;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: $zindex-floating;
|
||||
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 {
|
||||
.actionColumn {
|
||||
background-color: $gray-1250;
|
||||
border-radius: 2px;
|
||||
padding: 0.25rem;
|
||||
width: calc(2rem + 1px); // button + padding + margin
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
opacity: $opacity-disabled !important; // fighting inline styles
|
||||
.indexColumn {
|
||||
width: 3.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.delayedTime {
|
||||
color: $ontime-delay-text;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
cursor: col-resize;
|
||||
opacity: $opacity-disabled;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
|
||||
import { OntimeEntry, TimeField } from 'ontime-types';
|
||||
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
||||
import { useCuesheetOptions } from '../cuesheet.options';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
|
||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||
@@ -18,21 +18,59 @@ import style from './CuesheetTable.module.scss';
|
||||
interface CuesheetTableProps {
|
||||
data: OntimeEntry[];
|
||||
columns: ColumnDef<OntimeEntry>[];
|
||||
showModal: (eventId: MaybeString) => void;
|
||||
}
|
||||
|
||||
export default function CuesheetTable({ data, columns, showModal }: CuesheetTableProps) {
|
||||
export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
|
||||
const { updateEntry, updateTimer } = useEntryActions();
|
||||
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||
useColumnManager(columns);
|
||||
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
|
||||
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
||||
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followPlayback });
|
||||
|
||||
const { listeners } = useTableNav();
|
||||
|
||||
const meta = useMemo(
|
||||
() => ({
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if there is no value change
|
||||
const key = accessor as keyof OntimeEntry;
|
||||
const previousValue = event[key];
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
updateEntry({ id: event.id, custom: { [accessor]: payload } });
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry({ id: event.id, [accessor]: payload });
|
||||
},
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => {
|
||||
// the timer element already contains logic to avoid submitting a unchanged value
|
||||
updateTimer(eventId, field, payload, true);
|
||||
},
|
||||
options: {
|
||||
showDelayedTimes,
|
||||
hideTableSeconds,
|
||||
},
|
||||
}),
|
||||
[data, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
);
|
||||
|
||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||
useColumnManager(columns);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -45,52 +83,36 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta: {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if there is no value change
|
||||
const key = accessor as keyof OntimeEvent;
|
||||
const previousValue = event[key];
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
updateEntry({ id: event.id, custom: { [accessor]: payload } });
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry({ id: event.id, [accessor]: payload });
|
||||
},
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
|
||||
// the timer element already contains logic to avoid submitting a unchanged value
|
||||
updateTimer(eventId, field, payload, true);
|
||||
},
|
||||
options: {
|
||||
showDelayedTimes,
|
||||
hideTableSeconds,
|
||||
},
|
||||
},
|
||||
meta,
|
||||
});
|
||||
|
||||
const setAllVisible = useCallback(() => {
|
||||
table.toggleAllColumnsVisible(true);
|
||||
}, []);
|
||||
}, [table]);
|
||||
|
||||
const resetColumnResizing = useCallback(() => {
|
||||
setColumnSizing({});
|
||||
}, []);
|
||||
}, [setColumnSizing]);
|
||||
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const rowModel = table.getRowModel();
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
* and pass them as CSS variables to the table container.
|
||||
*/
|
||||
const columnSizeVars = useMemo(() => {
|
||||
const headers = table.getFlatHeaders();
|
||||
const colSizes: { [key: string]: number } = {};
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const header = headers[i]!;
|
||||
colSizes[`--header-${header.id}-size`] = header.getSize();
|
||||
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
|
||||
}
|
||||
return colSizes;
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CuesheetTableSettings
|
||||
@@ -100,12 +122,24 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
||||
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
) : (
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
<CuesheetTableMenu showModal={showModal} />
|
||||
<CuesheetTableMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* While dragging, we avoid re-rendering the body by render
|
||||
*/
|
||||
const MemoisedBody = memo(
|
||||
CuesheetBody,
|
||||
(prev, next) => prev.table.options.data === next.table.options.data,
|
||||
) as typeof CuesheetBody;
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
@import '../CuesheetTable.module.scss';
|
||||
|
||||
.blockRow {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
font-size: 1rem;
|
||||
border-radius: 2px 0 0 0;
|
||||
background-color: $gray-1300;
|
||||
border-left: 4px solid var(--user-bg, $gray-500);
|
||||
background: color-mix(in srgb, transparent 90%, var(--user-bg, $gray-500) 10%);
|
||||
|
||||
position: relative;
|
||||
line-height: 1em;
|
||||
|
||||
td {
|
||||
min-height: 3.5rem;
|
||||
padding-top: 0.75rem !important; // fighting styles from cuesheet-table
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid $blue-500;
|
||||
outline-offset: -1px;
|
||||
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
|
||||
}
|
||||
}
|
||||
+51
-28
@@ -1,47 +1,70 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry } from 'ontime-types';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
import style from './BlockRow.module.scss';
|
||||
|
||||
interface BlockRowProps {
|
||||
blockId: EntryId;
|
||||
colour: string;
|
||||
hidePast: boolean;
|
||||
title: string;
|
||||
columnCount: number;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<OntimeEntry>;
|
||||
}
|
||||
|
||||
function BlockRow({ hidePast, title, columnCount }: BlockRowProps) {
|
||||
export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) {
|
||||
const { currentBlockId } = useCurrentBlockId();
|
||||
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
|
||||
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
if (hidePast && !currentBlockId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// guard the use case where user has hidden all columns
|
||||
const fillColumns = Math.min(columnCount, 1);
|
||||
|
||||
const paddingRows = new Array(fillColumns).fill(null);
|
||||
|
||||
return (
|
||||
<tr className={style.blockRow}>
|
||||
<td tabIndex={-1} role='cell' ref={firstCellRef}>
|
||||
{title}
|
||||
</td>
|
||||
{paddingRows.map((_value, index) => {
|
||||
return (
|
||||
<td
|
||||
key={index}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
onFocus={() => {
|
||||
firstCellRef.current?.focus();
|
||||
<tr className={style.blockRow} style={{ '--user-bg': colour }}>
|
||||
{showActionMenu && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
variant='subtle-white'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const yPos = 8 + rect.y + rect.height / 2;
|
||||
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<IoEllipsisHorizontal />
|
||||
</IconButton>
|
||||
</td>
|
||||
)}
|
||||
{!hideIndexColumn && <td className={style.indexColumn} tabIndex={-1} role='cell' />}
|
||||
{table
|
||||
.getRow(rowId)
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
}}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BlockRow);
|
||||
|
||||
+65
-8
@@ -1,16 +1,19 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { MutableRefObject, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RowModel, Table } from '@tanstack/react-table';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../../../../common/api/constants';
|
||||
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
||||
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../../cuesheet/cuesheet.options';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import BlockRow from './BlockRow';
|
||||
import DelayRow from './DelayRow';
|
||||
import EventRow from './EventRow';
|
||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||
|
||||
interface CuesheetBodyProps {
|
||||
rowModel: RowModel<OntimeEntry>;
|
||||
@@ -19,8 +22,29 @@ interface CuesheetBodyProps {
|
||||
}
|
||||
|
||||
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { hideDelays, hidePast } = useCuesheetOptions();
|
||||
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||
|
||||
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore();
|
||||
|
||||
const observer = useMemo(
|
||||
() =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
addVisibleRow(entry.target.id);
|
||||
} else {
|
||||
removeVisibleRow(entry.target.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '400px' },
|
||||
),
|
||||
[addVisibleRow, removeVisibleRow],
|
||||
);
|
||||
|
||||
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||
const getColumnHash = lazyEvaluate(() => {
|
||||
@@ -36,6 +60,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
let isPast = Boolean(selectedEventId);
|
||||
let hadBlock = false;
|
||||
return (
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
@@ -47,8 +72,17 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
const columnCount = getVisibleColumns().length;
|
||||
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||
return (
|
||||
<BlockRow
|
||||
key={key}
|
||||
blockId={entry.id}
|
||||
colour={entry.colour}
|
||||
hidePast={isPast && hidePast}
|
||||
rowId={row.id}
|
||||
rowIndex={row.index}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isOntimeDelay(entry)) {
|
||||
if (isPast && hidePast) {
|
||||
@@ -59,20 +93,27 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DelayRow key={key} duration={delayVal} />;
|
||||
let parentBgColour: string | null = null;
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent];
|
||||
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
|
||||
}
|
||||
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
|
||||
}
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
const columnHash = getColumnHash();
|
||||
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
rowBgColour = '#087A27'; // $active-green
|
||||
} else if (entry.colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
|
||||
@@ -84,6 +125,19 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
}
|
||||
}
|
||||
|
||||
let parentBgColour: string | undefined;
|
||||
let firstAfterBlock = false;
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent];
|
||||
parentBgColour = (parentEntry as OntimeBlock).colour;
|
||||
hadBlock = true;
|
||||
} else if (hadBlock) {
|
||||
firstAfterBlock = true;
|
||||
hadBlock = false;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={row.id}
|
||||
@@ -94,8 +148,11 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
rowBgColour={rowBgColour}
|
||||
parentBgColour={parentBgColour}
|
||||
table={table}
|
||||
firstAfterBlock={firstAfterBlock}
|
||||
columnHash={columnHash}
|
||||
observer={observer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-4
@@ -3,7 +3,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import { SortableCell } from './SortableCell';
|
||||
|
||||
@@ -14,7 +14,8 @@ interface CuesheetHeaderProps {
|
||||
}
|
||||
|
||||
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
@@ -31,7 +32,6 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||
)}
|
||||
<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;
|
||||
|
||||
@@ -42,7 +42,11 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
||||
<SortableCell
|
||||
key={header.column.columnDef.id}
|
||||
header={header}
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
@import '../CuesheetTable.module.scss';
|
||||
|
||||
.delayRow {
|
||||
width: calc(100vw - 2rem);
|
||||
color: $ontime-delay-text;
|
||||
border-left: 4px solid var(--user-bg);
|
||||
|
||||
td {
|
||||
width: 100%;
|
||||
padding-block: 0.5rem;
|
||||
text-align: center;
|
||||
transform: translateX(45%);
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,23 @@ import { memo } from 'react';
|
||||
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
import style from './DelayRow.module.scss';
|
||||
|
||||
interface DelayRowProps {
|
||||
duration: number;
|
||||
parentBgColour: string | null;
|
||||
}
|
||||
|
||||
function DelayRow({ duration }: DelayRowProps) {
|
||||
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
|
||||
const delayTime = millisToDelayString(duration, 'expanded');
|
||||
|
||||
return (
|
||||
<tr className={style.delayRow}>
|
||||
<tr
|
||||
className={style.delayRow}
|
||||
style={{
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
}}
|
||||
>
|
||||
<td tabIndex={0} role='cell'>
|
||||
{delayTime}
|
||||
</td>
|
||||
|
||||
+11
@@ -1,3 +1,12 @@
|
||||
.imageInput {
|
||||
&::placeholder {
|
||||
opacity: 0.2;
|
||||
}
|
||||
&:hover::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.imageCell {
|
||||
position: relative;
|
||||
min-height: 2rem;
|
||||
@@ -6,6 +15,8 @@
|
||||
.overlay {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 1rem;
|
||||
background-color: $black-60;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
|
||||
import style from './EditableImage.module.scss';
|
||||
@@ -22,10 +23,17 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
|
||||
updateValue(newValue);
|
||||
};
|
||||
|
||||
const openInNewTab = () => {
|
||||
if (initialValue) {
|
||||
window.open(initialValue, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
if (!initialValue) {
|
||||
return (
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
placeholder='Paste image URL'
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
@@ -42,7 +50,12 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
|
||||
return (
|
||||
<div className={style.imageCell}>
|
||||
<div className={style.overlay}>
|
||||
<button onClick={() => handleUpdate('')}>Delete</button>
|
||||
<Button variant='subtle-white' onClick={openInNewTab}>
|
||||
Preview
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<img loading='lazy' src={initialValue} className={style.image} />
|
||||
</div>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@import "../CuesheetTable.module.scss";
|
||||
|
||||
.eventRow {
|
||||
vertical-align: top;
|
||||
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
|
||||
border-left: 4px solid var(--user-bg, $gray-500);
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid $blue-500;
|
||||
outline-offset: -1px;
|
||||
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
|
||||
}
|
||||
|
||||
&.firstAfterBlock {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(255, 255, 255, 0.03),
|
||||
rgba(255, 255, 255, 0.03) 10px,
|
||||
rgba(255, 255, 255, 0.08) 10px,
|
||||
rgba(255, 255, 255, 0.08) 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $gray-1200;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
+43
-27
@@ -1,4 +1,4 @@
|
||||
import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
||||
@@ -6,10 +6,12 @@ import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
interface EventRowProps {
|
||||
rowId: string;
|
||||
@@ -21,9 +23,12 @@ interface EventRowProps {
|
||||
skip?: boolean;
|
||||
colour?: string;
|
||||
rowBgColour?: string;
|
||||
parentBgColour?: string;
|
||||
table: Table<OntimeEntry>;
|
||||
/** hack to force re-rendering of the row when the column sizes change */
|
||||
columnHash: string;
|
||||
observer: IntersectionObserver;
|
||||
firstAfterBlock: boolean;
|
||||
}
|
||||
|
||||
export default memo(EventRow, (prevProps, nextProps) => {
|
||||
@@ -35,34 +40,36 @@ export default memo(EventRow, (prevProps, nextProps) => {
|
||||
prevProps.isPast === nextProps.isPast &&
|
||||
prevProps.selectedRef === nextProps.selectedRef &&
|
||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
||||
prevProps.parentBgColour === nextProps.parentBgColour &&
|
||||
prevProps.columnHash === nextProps.columnHash
|
||||
);
|
||||
});
|
||||
|
||||
function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table }: EventRowProps) {
|
||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||
function EventRow({
|
||||
rowId,
|
||||
event,
|
||||
eventIndex,
|
||||
rowIndex,
|
||||
isPast,
|
||||
selectedRef,
|
||||
rowBgColour,
|
||||
parentBgColour,
|
||||
table,
|
||||
observer,
|
||||
firstAfterBlock,
|
||||
}: EventRowProps) {
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
// store a reference of the row in the observer
|
||||
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) {
|
||||
if (handleRefCurrent) {
|
||||
observer.observe(handleRefCurrent);
|
||||
}
|
||||
|
||||
@@ -71,22 +78,28 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
|
||||
observer.unobserve(handleRefCurrent);
|
||||
}
|
||||
};
|
||||
}, [ownRef, selectedRef]);
|
||||
}, [observer]);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.6 });
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cx([style.eventRow, event.skip ?? style.skip])}
|
||||
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
|
||||
id={rowId}
|
||||
className={cx([style.eventRow, event.skip && style.skip, firstAfterBlock && style.firstAfterBlock, Boolean(parentBgColour) && style.hasParent])}
|
||||
style={{
|
||||
opacity: `${isPast ? '0.2' : '1'}`,
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
}}
|
||||
ref={selectedRef ?? ownRef}
|
||||
>
|
||||
{showActionMenu && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
variant='subtle-white'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const yPos = 8 + rect.y + rect.height / 2;
|
||||
@@ -105,12 +118,15 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
|
||||
{isVisible
|
||||
? table
|
||||
.getRow(rowId)
|
||||
?.getVisibleCells()
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
backgroundColor: rowBgColour,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.muted {
|
||||
opacity: 0.4; // same as the time input with muted text
|
||||
line-height: 2rem; // input height
|
||||
}
|
||||
|
||||
.numeric {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './MutedText.module.scss';
|
||||
|
||||
interface MutedTextProps {
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
export default function MutedText({ numeric, children }: PropsWithChildren<MutedTextProps>) {
|
||||
return <span className={cx([style.muted, numeric && style.numeric])}>{children}</span>;
|
||||
}
|
||||
+6
-5
@@ -4,15 +4,15 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import styles from '../CuesheetTable.module.scss';
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface SortableCellProps {
|
||||
header: Header<OntimeEntry, unknown>;
|
||||
style: CSSProperties;
|
||||
injectedStyles: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
|
||||
const { column, colSpan } = header;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -21,7 +21,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
...style,
|
||||
...injectedStyles,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
@@ -34,10 +34,11 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
</div>
|
||||
<div
|
||||
{...{
|
||||
onDoubleClick: () => header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
className={styles.resizer}
|
||||
className={style.resizer}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
|
||||
+3
-1
@@ -7,10 +7,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
&.delayed {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
+52
-35
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
|
||||
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
@@ -9,6 +9,7 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
@@ -17,24 +18,29 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
|
||||
return null;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
return <MutedText numeric>{formatTime(getValue() as number)}</MutedText>;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
|
||||
|
||||
const startTime = getValue() as number;
|
||||
const isStartLocked = !(row.original as OntimeEvent).linkStart;
|
||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
||||
const isStartLocked = !event.linkStart;
|
||||
|
||||
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
|
||||
const displayTime = showDelayedTimes ? startTime + event.delay : startTime;
|
||||
|
||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||
const formattedTime = formatTime(displayTime, formatOpts);
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
|
||||
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={event.delay !== 0}>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
|
||||
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(startTime)} />
|
||||
</TimeInput>
|
||||
);
|
||||
}
|
||||
@@ -44,24 +50,29 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
return <MutedText numeric>{formatTime(getValue() as number, formatOpts)}</MutedText>;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
|
||||
|
||||
const endTime = getValue() as number;
|
||||
const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd;
|
||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
||||
const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd;
|
||||
|
||||
const displayTime = showDelayedTimes ? endTime + delayValue : endTime;
|
||||
const displayTime = showDelayedTimes ? endTime + event.delay : endTime;
|
||||
|
||||
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
|
||||
const formattedTime = formatTime(displayTime, formatOpts);
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
|
||||
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={event.delay !== 0}>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
|
||||
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(endTime)} />
|
||||
</TimeInput>
|
||||
);
|
||||
}
|
||||
@@ -71,13 +82,19 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown
|
||||
return null;
|
||||
}
|
||||
|
||||
const { hideTableSeconds } = table.options.meta.options;
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
|
||||
|
||||
const duration = getValue() as number;
|
||||
const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration;
|
||||
const formattedDuration = formatDuration(duration, false);
|
||||
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
|
||||
const formattedDuration = formatDuration(duration, hideTableSeconds);
|
||||
|
||||
return (
|
||||
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
|
||||
@@ -91,17 +108,15 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
[column.id, row.index, table.options.meta],
|
||||
);
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
// not all entries have all properties (eg blocks)
|
||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||
if (initialValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
|
||||
|
||||
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
@@ -110,12 +125,11 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
[column.id, row.index, table.options.meta],
|
||||
);
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
if (isOntimeDelay(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -128,17 +142,15 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
[column.id, row.index, table.options.meta],
|
||||
);
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
// not all entries have all properties (eg blocks)
|
||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||
if (initialValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
|
||||
|
||||
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
@@ -147,20 +159,25 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
[column.id, row.index, table.options.meta],
|
||||
);
|
||||
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
if (isOntimeDelay(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// fields will not contain the field if there is no value set by the user
|
||||
// event if there is no initial value, we still render the cell
|
||||
const initialValue = event.custom[column.id] ?? '';
|
||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
|
||||
/**
|
||||
* we cant use the createColumnHelper() because we have custom logic for rendering the cells
|
||||
* This means that the display columns: index and action are added inline by the row components
|
||||
*/
|
||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
||||
accessorKey: key,
|
||||
id: key,
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface VisibleRowsStore {
|
||||
visibleRows: Set<string>;
|
||||
addVisibleRow: (id: string) => void;
|
||||
removeVisibleRow: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useVisibleRowsStore = create<VisibleRowsStore>((set) => ({
|
||||
visibleRows: new Set(),
|
||||
addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })),
|
||||
removeVisibleRow: (id) =>
|
||||
set((state) => {
|
||||
const newSet = new Set(state.visibleRows);
|
||||
newSet.delete(id);
|
||||
return { visibleRows: newSet };
|
||||
}),
|
||||
}));
|
||||
+53
-8
@@ -1,17 +1,38 @@
|
||||
import { memo } from 'react';
|
||||
import { Menu, MenuButton, Portal } from '@chakra-ui/react';
|
||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
|
||||
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import { cloneEvent } from '../../../../common/utils/clone';
|
||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
||||
|
||||
import CuesheetTableMenuActionsProps from './CuesheetTableMenuActions';
|
||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||
|
||||
interface CuesheetTableMenuProps {
|
||||
showModal: (eventId: string) => void;
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableMenu);
|
||||
|
||||
function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
|
||||
function CuesheetTableMenu() {
|
||||
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
|
||||
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
|
||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
||||
|
||||
const handleCloneEvent = () => {
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEvent = getEntryById(eventId);
|
||||
if (!currentEvent || !isOntimeEvent(currentEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEvent = cloneEvent(currentEvent);
|
||||
try {
|
||||
addEntry(newEvent, { after: eventId });
|
||||
} catch (_error) {
|
||||
// we do not handle errors here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -26,7 +47,31 @@ function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
|
||||
w={1}
|
||||
h={1}
|
||||
/>
|
||||
<CuesheetTableMenuActionsProps eventId={eventId} entryIndex={entryIndex} showModal={showModal} />
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
|
||||
Edit ...
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
|
||||
Add event above
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
|
||||
Add event below
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
|
||||
Move up
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
|
||||
Move down
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</Portal>
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
||||
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import { cloneEvent } from '../../../../common/utils/clone';
|
||||
|
||||
interface CuesheetTableMenuActionsProps {
|
||||
eventId: string;
|
||||
entryIndex: number;
|
||||
showModal: (entryId: string) => void;
|
||||
}
|
||||
|
||||
export default function CuesheetTableMenuActions({ eventId, entryIndex, showModal }: CuesheetTableMenuActionsProps) {
|
||||
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
|
||||
|
||||
const handleCloneEvent = () => {
|
||||
const currentEvent = getEntryById(eventId);
|
||||
if (!currentEvent || !isOntimeEvent(currentEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEvent = cloneEvent(currentEvent);
|
||||
try {
|
||||
addEntry(newEvent, { after: eventId });
|
||||
} catch (_error) {
|
||||
// we do not handle errors here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
|
||||
Edit ...
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
|
||||
Add event above
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
|
||||
Add event below
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
|
||||
Move up
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
|
||||
Move down
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
);
|
||||
}
|
||||
+19
-6
@@ -1,13 +1,13 @@
|
||||
.tableSettings {
|
||||
margin-top: 1rem;
|
||||
grid-area: settings;
|
||||
padding-inline: 0.5rem;
|
||||
background-color: $gray-1250;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
gap: 5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
@media (max-width: $small-screen) {
|
||||
gap: 1rem;
|
||||
}
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
@@ -27,3 +27,16 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
+135
-15
@@ -1,10 +1,15 @@
|
||||
import { memo, ReactNode } from 'react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { IoChevronDown, IoLocate, IoOptions, IoSettingsOutline } from 'react-icons/io5';
|
||||
import { Popover } from '@base-ui-components/react/popover';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import RotatedLink from '../../../../common/components/icons/RotatedLink';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
@@ -15,6 +20,7 @@ interface CuesheetTableSettingsProps {
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableSettings);
|
||||
function CuesheetTableSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
@@ -23,9 +29,126 @@ function CuesheetTableSettings({
|
||||
}: CuesheetTableSettingsProps) {
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
<div>
|
||||
<Editor.Label className={style.sectionTitle}>Toggle column visibility</Editor.Label>
|
||||
<div className={style.row}>
|
||||
<div className={style.inline}>
|
||||
<ViewSettings />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style.inline}>
|
||||
<ViewSettingsFollowButton />
|
||||
|
||||
<Editor.Separator orientation='vertical' />
|
||||
|
||||
<Button variant='subtle'>
|
||||
<RotatedLink />
|
||||
Share...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSettingsFollowButton() {
|
||||
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
|
||||
const toggle = usePersistedCuesheetOptions((state) => state.toggleOption);
|
||||
|
||||
return (
|
||||
<Button variant={followPlayback ? 'primary' : 'subtle'} onClick={() => toggle('followPlayback')}>
|
||||
<IoLocate />
|
||||
{followPlayback ? 'Following playback' : 'Follow playback'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSettings() {
|
||||
const options = usePersistedCuesheetOptions();
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<Button variant='ghosted'>
|
||||
<IoSettingsOutline /> Settings
|
||||
<IoChevronDown />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContents align='start' className={style.column}>
|
||||
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.showActionMenu}
|
||||
onCheckedChange={(checked) => options.setOption('showActionMenu', checked)}
|
||||
/>
|
||||
Show action menu
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideTableSeconds}
|
||||
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
|
||||
/>
|
||||
Hide seconds in table
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hidePast}
|
||||
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
|
||||
/>
|
||||
Hide past events
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideIndexColumn}
|
||||
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
|
||||
/>
|
||||
Hide index column
|
||||
</Editor.Label>
|
||||
|
||||
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.showDelayedTimes}
|
||||
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
|
||||
/>
|
||||
Show delayed times
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideDelays}
|
||||
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
|
||||
/>
|
||||
Hide delay entries
|
||||
</Editor.Label>
|
||||
</PopoverContents>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: CuesheetTableSettingsProps) {
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<Button variant='ghosted'>
|
||||
<IoOptions /> View
|
||||
<IoChevronDown />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContents align='start' className={style.inline}>
|
||||
<div className={style.column}>
|
||||
<Editor.Label className={style.sectionTitle}>Column visibility</Editor.Label>
|
||||
{columns.map((column) => {
|
||||
const columnHeader = column.columnDef.header;
|
||||
const visible = column.getIsVisible();
|
||||
@@ -37,23 +160,20 @@ function CuesheetTableSettings({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
|
||||
<div className={style.row}>
|
||||
<Button size='small' variant='subtle' onClick={handleClearToggles}>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<div className={style.column}>
|
||||
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
|
||||
<Button size='small' fluid onClick={handleClearToggles}>
|
||||
Show All
|
||||
</Button>
|
||||
<Button size='small' variant='subtle' onClick={handleResetResizing}>
|
||||
<Button size='small' fluid onClick={handleResetResizing}>
|
||||
Reset Resizing
|
||||
</Button>
|
||||
<Button size='small' variant='subtle' onClick={handleResetReordering}>
|
||||
<Button size='small' fluid onClick={handleResetReordering}>
|
||||
Reset Reordering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContents>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableSettings);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||
|
||||
// if the columns change, we update the dataset
|
||||
// if the columns order changes, we update the dataset
|
||||
useEffect(() => {
|
||||
let shouldReplace = false;
|
||||
const newColumns: string[] = [];
|
||||
|
||||
@@ -1,110 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
/**
|
||||
* In the specific case of the cuesheet options
|
||||
* we save the user preferences in the local storage
|
||||
*/
|
||||
export const cuesheetOptions: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'showActionMenu',
|
||||
title: 'Show action menu',
|
||||
description: 'Whether to show the action menu for every row in the table',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideTableSeconds',
|
||||
title: 'Hide seconds in table',
|
||||
description: 'Whether to hide seconds in the time fields displayed in the table',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'followSelected',
|
||||
title: 'Follow selected event',
|
||||
description: 'Whether the view should automatically scroll to the selected event',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to hide events that have passed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideIndexColumn',
|
||||
title: 'Hide index column',
|
||||
description: 'Whether the hide the event indexes in the table',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.BehaviourOptions,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'showDelayedTimes',
|
||||
title: 'Show delayed times',
|
||||
description: 'Whether the time fields should include delays',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideDelays',
|
||||
title: 'Hide delays',
|
||||
description: 'Whether to hide the rows containing scheduled delays',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
type CuesheetOptions = {
|
||||
type OptionValues = {
|
||||
showActionMenu: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
followSelected: boolean;
|
||||
followPlayback: boolean;
|
||||
hidePast: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideDelays: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
* the names and fallbacks are manually matched with cuesheetOptions
|
||||
*/
|
||||
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
|
||||
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
|
||||
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
|
||||
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
|
||||
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
|
||||
};
|
||||
const defaultOptions: OptionValues = {
|
||||
showActionMenu: false,
|
||||
hideTableSeconds: false,
|
||||
followPlayback: false,
|
||||
hidePast: false,
|
||||
hideIndexColumn: false,
|
||||
showDelayedTimes: false,
|
||||
hideDelays: false,
|
||||
};
|
||||
|
||||
export type CuesheetOptionKeys = keyof OptionValues;
|
||||
|
||||
export interface CuesheetOptions extends OptionValues {
|
||||
setOption: <K extends CuesheetOptionKeys>(key: K, value: OptionValues[K]) => void;
|
||||
toggleOption: (key: CuesheetOptionKeys) => void;
|
||||
resetOptions: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the cuesheet view options
|
||||
*/
|
||||
export function useCuesheetOptions(): CuesheetOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
export const usePersistedCuesheetOptions = create<CuesheetOptions>()(
|
||||
persist(
|
||||
(set) => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
setOption: (key, value) => set((state) => ({ ...state, [key]: value })),
|
||||
toggleOption: (key) => set((state) => ({ ...state, [key]: !state[key] })),
|
||||
resetOptions: () => set(defaultOptions),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: 'cuesheet-options',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useElectronListener } from '../../common/hooks/useElectronEvent';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import AppSettings from '../../features/app-settings/AppSettings';
|
||||
import useAppSettingsNavigation from '../../features/app-settings/useAppSettingsNavigation';
|
||||
import { EditorOverview } from '../../features/overview/Overview';
|
||||
import EditorOverview from '../../features/overview/EditorOverview';
|
||||
|
||||
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIsMobile } from '../../common/hooks/useIsMobile';
|
||||
import { useIsMobileDevice } from '../../common/hooks/useIsMobileDevice';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
@@ -17,7 +17,7 @@ interface StudioClockProps {
|
||||
}
|
||||
|
||||
export default function StudioClock({ onAir, clock, hideCards }: StudioClockProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobile = useIsMobileDevice();
|
||||
|
||||
// if we are on mobile and have to show the cards
|
||||
if (isMobile && !hideCards) {
|
||||
|
||||
Reference in New Issue
Block a user