feat: show welcome screen on first login

This commit is contained in:
Carlos Valente
2024-11-22 21:57:30 +01:00
committed by Carlos Valente
parent 7bf90d669a
commit 22fe65c655
9 changed files with 327 additions and 0 deletions
@@ -0,0 +1,24 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
interface DialogStore {
showDialog: MaybeString;
setDialog: (name: string) => void;
clearDialog: () => void;
}
/**
* Used to allow the server to show a dialog in the client
*/
export const useDialogStore = create<DialogStore>((set) => ({
showDialog: null,
clearDialog: () => set({ showDialog: null }),
setDialog: (name) => set({ showDialog: name }),
}));
/**
* Allows setting a dialog to show (outside react)
*/
export function addDialog(name: string): void {
useDialogStore.getState().setDialog(name);
}
+8
View File
@@ -12,6 +12,7 @@ import {
setClientRedirect,
setClients,
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
@@ -118,6 +119,13 @@ export const connectSocket = () => {
break;
}
case 'dialog': {
if (payload.dialog === 'welcome') {
addDialog('welcome');
}
break;
}
case 'ontime-log': {
addLog(payload as Log);
break;
@@ -13,6 +13,7 @@ import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss';
@@ -55,6 +56,7 @@ export default function Editor() {
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview>
@@ -0,0 +1,64 @@
.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;
}
.tableContainer {
max-height: 350px;
overflow-y: auto;
}
.table {
width: 100%;
tbody {
background-color: $gray-1300;
tr {
&:has(:hover) {
background-color: $gray-1350;
}
}
}
tr {
height: 2rem;
}
th,
td {
padding-inline: 0.5rem;
}
th {
text-align: left;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.current {
background-color: $blue-700;
}
@@ -0,0 +1,99 @@
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/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} 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>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div>
</div>
</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,46 @@
import { Button } from '@chakra-ui/react';
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}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr>
);
}
return (
<tr key={project.filename}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr>
);
})}
</tbody>
);
}
@@ -34,6 +34,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null;
private isFirstEditor = true;
constructor() {
if (instance) {
@@ -134,7 +135,18 @@ export class SocketServer implements IAdapter {
const previousData = this.clients.get(clientId);
previousData.path = payload;
this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.isFirstEditor) {
this.isFirstEditor = false;
ws.send(
JSON.stringify({
type: 'dialog',
payload: { dialog: 'welcome' },
}),
);
}
}
this.sendClientList();
return;
}