diff --git a/apps/client/src/common/stores/dialogStore.ts b/apps/client/src/common/stores/dialogStore.ts new file mode 100644 index 000000000..065e9e4da --- /dev/null +++ b/apps/client/src/common/stores/dialogStore.ts @@ -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((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); +} diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 4a3446011..ff0013bd0 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -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; diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 0fc2045b8..150c4cba2 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -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 (
+ diff --git a/apps/client/src/features/editors/welcome/Welcome.module.scss b/apps/client/src/features/editors/welcome/Welcome.module.scss new file mode 100644 index 000000000..3965310ea --- /dev/null +++ b/apps/client/src/features/editors/welcome/Welcome.module.scss @@ -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; +} diff --git a/apps/client/src/features/editors/welcome/Welcome.tsx b/apps/client/src/features/editors/welcome/Welcome.tsx new file mode 100644 index 000000000..1090b67da --- /dev/null +++ b/apps/client/src/features/editors/welcome/Welcome.tsx @@ -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 ( + + + + + +
+
+ ontime +
Ontime v{appVersion}
+ Website + Read the docs + Discord server +
+
+
Welcome to Ontime
+
+ + + + + + + + +
Project NameLast Used +
+
+ +
+ + + +
+
+
+
+
+
+ ); +} diff --git a/apps/client/src/features/editors/welcome/WelcomePlacement.tsx b/apps/client/src/features/editors/welcome/WelcomePlacement.tsx new file mode 100644 index 000000000..2ab0fb593 --- /dev/null +++ b/apps/client/src/features/editors/welcome/WelcomePlacement.tsx @@ -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 ; +} diff --git a/apps/client/src/features/editors/welcome/composite/ImportProjectButton.tsx b/apps/client/src/features/editors/welcome/composite/ImportProjectButton.tsx new file mode 100644 index 000000000..7ab6f4f70 --- /dev/null +++ b/apps/client/src/features/editors/welcome/composite/ImportProjectButton.tsx @@ -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(null); + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleImport = async (event: ChangeEvent) => { + 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 ( + <> + + + + + ); +} diff --git a/apps/client/src/features/editors/welcome/composite/WelcomeProjectList.tsx b/apps/client/src/features/editors/welcome/composite/WelcomeProjectList.tsx new file mode 100644 index 000000000..5ebf17d3e --- /dev/null +++ b/apps/client/src/features/editors/welcome/composite/WelcomeProjectList.tsx @@ -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; + onClose: () => void; +} + +export default function WelcomeProjectList(props: WelcomeProjectListProps) { + const { loadProject, onClose } = props; + const { data } = useOrderedProjectList(); + + return ( + + {data.reorderedProjectFiles.map((project) => { + if (project.filename === data.lastLoadedProject) { + return ( + + {project.filename} + Loaded from last session + + + + + ); + } + return ( + + {project.filename} + {new Date(project.updatedAt).toLocaleString()} + + + + + ); + })} + + ); +} diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index ce7efa5cd..6fd5e7274 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -34,6 +34,7 @@ export class SocketServer implements IAdapter { private wss: WebSocketServer | null; private readonly clients: Map; 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; }