refactor: expose editor panels in small device

This commit is contained in:
Carlos Valente
2025-06-23 06:59:34 +02:00
committed by Carlos Valente
parent b991627cb7
commit 984dcb0181
52 changed files with 336 additions and 131 deletions
@@ -3,7 +3,7 @@ import { Button, Checkbox } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import * as Editor from '../../../../features/editors/editor-utils/EditorUtils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import style from './CuesheetTableSettings.module.scss';
@@ -0,0 +1,62 @@
@use './EditorMixin' as editor;
$min-playback-width: 27rem;
$max-playback-width: 30rem;
$panel-gap: 0.5rem;
.mainContainer {
background-color: $ui-black;
width: 100%;
height: 100%;
color: $ui-white;
padding: 0.5rem;
display: grid;
grid-template-columns: auto;
grid-template-rows: 3rem 1fr;
grid-template-areas:
'overview'
'main';
gap: $panel-gap;
}
.panelContainer {
grid-area: main;
display: flex;
gap: $panel-gap;
overflow: hidden;
.rundown,
.playback,
.messages {
position: relative;
border-radius: var(--editor--panel__br);
background-color: $bg-container-l2;
padding: 1rem;
}
}
.left {
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
min-width: $min-playback-width;
max-width: $max-playback-width;
display: flex;
flex-direction: column;
gap: $panel-gap;
}
.messages {
flex: 1;
}
.content {
padding-top: 1.5rem;
}
.contentColumnLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
}
+82
View File
@@ -0,0 +1,82 @@
import { lazy, useCallback, useEffect } from 'react';
import { IoApps, IoClose, IoSettingsOutline } from 'react-icons/io5';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
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 WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
const TimerControl = lazy(() => import('../../features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
useWindowTitle('Editor');
// we need to register the listener to change the editor location
useElectronListener();
// listen to shutdown request from electron process
useEffect(() => {
if (window.process?.type === 'renderer') {
window.ipcRenderer.on('user-request-shutdown', () => {
setLocation('shutdown');
});
}
}, [setLocation]);
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
useHotkeys([['mod + ,', toggleSettings]]);
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant={isSettingsOpen ? 'ontime-subtle' : 'ontime-subtle-white'}
size='lg'
icon={isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
onClick={toggleSettings}
/>
</EditorOverview>
{isSettingsOpen ? (
<AppSettings />
) : (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
<Rundown />
</div>
)}
</div>
);
}
@@ -0,0 +1,17 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
// declare editor specific styling constants
:root {
--editor--panel__br: 8px;
}
@mixin panel() {
display: flex;
position: relative;
border-radius: var(--editor--panel__br);
height: 100%;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -0,0 +1,11 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Editor from './Editor';
export default function ProtectedEditor() {
return (
<ProtectRoute permission='editor'>
<Editor />
</ProtectRoute>
);
}
@@ -0,0 +1,69 @@
.entry,
.empty,
.error {
padding-inline: 0.5rem;
font-size: 1rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry[data-selected='true'] {
background-color: $blue-700;
}
.empty {
color: $label-gray;
}
.error {
color: $error-red;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
.index {
grid-area: index;
background-color: var(--color, $gray-1000);
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
align-self: center;
text-align: center;
}
.title {
grid-area: title;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
}
}
.footer {
font-size: calc(1rem - 2px);
color: $label-gray;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.scrollContainer {
max-height: 70vh;
overflow: auto;
}
@@ -0,0 +1,102 @@
import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEntry } from 'ontime-types';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import useFinder from './useFinder';
import style from './Finder.module.scss';
interface FinderProps {
isOpen: boolean;
onClose: () => void;
}
export default function Finder(props: FinderProps) {
const { isOpen, onClose } = props;
const { find, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
submit();
}
};
const submit = () => {
const selectedEvent = results[selected];
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
onClose();
};
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'>
<ModalBody onKeyDown={navigate}>
<Input size='lg' onChange={debouncedFind} variant='ontime-filled' placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
return (
<li
key={entry.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': colour }}>
{displayIndex}
</div>
<div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
</ul>
</ModalBody>
<ModalFooter className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,194 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12;
type FilterableBlock = {
type: SupportedEntry.Block;
id: string;
index: number;
title: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: string;
index: number;
eventIndex: number;
title: string;
cue: string;
colour: string;
};
type FilterableEntry = FilterableBlock | FilterableEvent;
export default function useFinder() {
const { data } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
/** clear results when source data changes */
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data]);
/** Returns a single item with a matching index */
const searchByIndex = (searchString: string) => {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the cue field */
const searchByCue = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the title field*/
const searchByTitle = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeBlock(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Block,
id: event.id,
index: i,
title: event.title,
} satisfies FilterableBlock);
}
}
}
return { results, error: null };
};
/** Filters the rundown to a given evaluation */
const find = (event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
};
return { find, results, error };
}
@@ -0,0 +1,79 @@
.sections {
display: grid;
grid-template-columns: 1fr 5fr;
gap: 2rem;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
font-size: 1.5rem;
}
.logo {
max-width: 100px;
height: auto;
}
.buttonRow {
margin-top: 1rem;
display: flex;
gap: 1rem;
justify-content: end;
:first-child {
margin-right: auto;
}
}
.tableContainer {
height: 350px;
max-height: 350px;
overflow-y: auto;
}
.table {
width: 100%;
tbody {
background-color: $gray-1300;
tr {
&:hover:not(.current) {
background-color: $gray-1350;
}
.current {
&:hover {
background-color: $blue-900;
}
}
}
}
tr {
height: 2rem;
cursor: pointer;
}
th,
td {
padding-inline: 0.5rem;
}
th {
text-align: left;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.current {
background-color: $blue-700;
&:hover {
background-color: $blue-900;
}
}
@@ -0,0 +1,108 @@
import { useNavigate } from 'react-router-dom';
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import ExternalLink from '../../../common/components/link/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList';
import style from './Welcome.module.scss';
interface WelcomeProps {
onClose: () => void;
}
export default function Welcome(props: WelcomeProps) {
const { onClose } = props;
const navigate = useNavigate();
/** handle cleanup actions before request closing the modal */
const handleClose = () => {
onClose();
};
/** handle loading a selected project */
const handleLoadProject = async (filename: string) => {
try {
await loadProject(filename);
await invalidateAllCaches();
handleClose();
} catch (_error) {
/** no error handling for now */
}
};
/** handle loading the demo project */
const handleLoadDemo = async () => {
try {
await loadDemo();
await invalidateAllCaches();
handleClose();
} catch (_error) {
/** no error handling for now */
}
};
/** handle redirect to create modal */
const handleCallCreate = () => {
navigate('/editor?settings=project__create');
handleClose();
};
return (
<Modal isOpen onClose={handleClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'>
<ModalCloseButton />
<ModalBody>
<div className={style.sections}>
<div className={style.column}>
<img src='ontime-logo.png' alt='ontime' className={style.logo} />
<div>Ontime v{appVersion}</div>
<ExternalLink href={websiteUrl}>Website</ExternalLink>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</div>
<div className={style.column}>
<div className={style.header}>Welcome to Ontime</div>
<Editor.Title>Select project</Editor.Title>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>File Name</th>
<th>Last Used</th>
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
</div>
</div>
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
<Checkbox
size='sm'
variant='ontime-ondark'
defaultChecked
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
>
Show this modal on next startup
</Checkbox>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,14 @@
import { useDialogStore } from '../../../common/stores/dialogStore';
import Welcome from './Welcome';
export default function WelcomePlacement() {
const showDialog = useDialogStore((state) => state.showDialog);
const clearDialog = useDialogStore((state) => state.clearDialog);
if (!showDialog) {
return null;
}
return <Welcome onClose={clearDialog} />;
}
@@ -0,0 +1,58 @@
/**
* Handles importing of a project in the welcome modal
* the logic is mostly duplicated from ManageProjects.tsx
*/
import { ChangeEvent, useRef } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
interface ImportProjectButtonProps {
onFinish: () => void;
}
export default function ImportProjectButton(props: ImportProjectButtonProps) {
const { onFinish } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target?.files?.[0];
if (!selectedFile) {
return;
}
try {
validateProjectFile(selectedFile);
await uploadProjectFile(selectedFile);
} catch (error) {
/** we do not handle errors here */
} finally {
await invalidateAllCaches();
onFinish();
}
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleImport}
accept='.json'
data-testid='file-input'
/>
<Button size='sm' variant='ontime-subtle' onClick={handleSelectFile}>
Import project
</Button>
</>
);
}
@@ -0,0 +1,34 @@
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss';
interface WelcomeProjectListProps {
loadProject: (filename: string) => Promise<void>;
onClose: () => void;
}
export default function WelcomeProjectList(props: WelcomeProjectListProps) {
const { loadProject, onClose } = props;
const { data } = useOrderedProjectList();
return (
<tbody>
{data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) {
return (
<tr className={style.current} key={project.filename} onClick={onClose}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
</tr>
);
}
return (
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
</tr>
);
})}
</tbody>
);
}