mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
refactor: expose editor panels in small device
This commit is contained in:
committed by
Carlos Valente
parent
b991627cb7
commit
984dcb0181
+1
-1
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user