mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 20:39:18 +00:00
feat: allow renaming connection (#457)
* feat: allow renaming connection
This commit is contained in:
@@ -9,6 +9,7 @@ import { AppContextProvider } from './common/context/AppContext';
|
|||||||
import { ContextMenuProvider } from './common/context/ContextMenuContext';
|
import { ContextMenuProvider } from './common/context/ContextMenuContext';
|
||||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
|
import { socketClientName } from './common/stores/connectionName';
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
import theme from './theme/theme';
|
import theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
@@ -18,7 +19,8 @@ import AppRouter from './AppRouter';
|
|||||||
// @ts-expect-error no types from font import
|
// @ts-expect-error no types from font import
|
||||||
import('typeface-open-sans');
|
import('typeface-open-sans');
|
||||||
|
|
||||||
connectSocket();
|
const preferredClientName = socketClientName.getState().name;
|
||||||
|
connectSocket(preferredClientName);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isElectron, sendToElectron } = useElectronEvent();
|
const { isElectron, sendToElectron } = useElectronEvent();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useDisclosure } from '@chakra-ui/react';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||||
@@ -14,9 +15,11 @@ import useFullscreen from '../../hooks/useFullscreen';
|
|||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
|
|
||||||
|
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
export default function NavigationMenu() {
|
function NavigationMenu() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||||
@@ -27,8 +30,10 @@ export default function NavigationMenu() {
|
|||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
useClickOutside(menuRef, () => setShowMenu(false));
|
useClickOutside(menuRef, () => setShowMenu(false));
|
||||||
|
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
|
||||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||||
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' });
|
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let fadeOut: NodeJS.Timeout | null = null;
|
let fadeOut: NodeJS.Timeout | null = null;
|
||||||
@@ -51,6 +56,7 @@ export default function NavigationMenu() {
|
|||||||
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
|
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
|
||||||
const handleFullscreen = () => toggleFullScreen();
|
const handleFullscreen = () => toggleFullScreen();
|
||||||
const handleMirror = () => toggleMirror();
|
const handleMirror = () => toggleMirror();
|
||||||
|
|
||||||
const showEditFormDrawer = () => {
|
const showEditFormDrawer = () => {
|
||||||
searchParams.append('edit', 'true');
|
searchParams.append('edit', 'true');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
@@ -58,6 +64,7 @@ export default function NavigationMenu() {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
|
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
|
||||||
|
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||||
<IoApps />
|
<IoApps />
|
||||||
@@ -92,9 +99,17 @@ export default function NavigationMenu() {
|
|||||||
Flip Screen
|
Flip Screen
|
||||||
<IoSwapVertical />
|
<IoSwapVertical />
|
||||||
</div>
|
</div>
|
||||||
{/*<div className={style.link} tabIndex={0}>*/}
|
<div
|
||||||
{/* Rename Client*/}
|
className={style.link}
|
||||||
{/*</div>*/}
|
tabIndex={0}
|
||||||
|
role='button'
|
||||||
|
onClick={onOpen}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
isKeyEnter(event) && onOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename Client
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
@@ -116,3 +131,5 @@ export default function NavigationMenu() {
|
|||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(NavigationMenu);
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
.modalBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setClientName } from '../../../hooks/useSocket';
|
||||||
|
import { useSocketClientName } from '../../../stores/connectionName';
|
||||||
|
|
||||||
|
import style from './RenameClientModal.module.scss';
|
||||||
|
|
||||||
|
interface RenameClientModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
||||||
|
const { name: clientName, persistName } = useSocketClientName();
|
||||||
|
const [newName, setNewName] = useState(clientName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNewName(clientName);
|
||||||
|
}, [isOpen, clientName]);
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (newName) {
|
||||||
|
await setClientName(newName);
|
||||||
|
persistName(newName);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size='sm'
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
motionPreset='slideInBottom'
|
||||||
|
scrollBehavior='inside'
|
||||||
|
preserveScrollBarGap
|
||||||
|
variant='ontime-small'
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rename client</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody className={style.modalBody}>
|
||||||
|
<Input
|
||||||
|
placeholder='Connection must have a name'
|
||||||
|
defaultValue={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isDisabled={newName === clientName || !newName}
|
||||||
|
onClick={handleRename}
|
||||||
|
width='100%'
|
||||||
|
variant='ontime-filled'
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,3 +105,5 @@ export const useTimer = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector, deepCompare);
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useStore } from 'zustand';
|
||||||
|
import { createStore } from 'zustand/vanilla';
|
||||||
|
|
||||||
|
interface SocketClientNameState {
|
||||||
|
name?: string;
|
||||||
|
setName: (newValue: string) => void;
|
||||||
|
persistName: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientNameKey = 'ontime-client-name';
|
||||||
|
|
||||||
|
function persistKeyToStorage(newValue: string) {
|
||||||
|
localStorage.setItem(clientNameKey, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
||||||
|
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||||
|
setName: (newValue: string) => set(() => ({ name: newValue })),
|
||||||
|
persistName: (newValue: string) =>
|
||||||
|
set(() => {
|
||||||
|
persistKeyToStorage(newValue);
|
||||||
|
return { name: newValue };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useSocketClientName = () => useStore(socketClientName);
|
||||||
@@ -2,6 +2,7 @@ import { Log, RuntimeStore } from 'ontime-types';
|
|||||||
|
|
||||||
import { RUNTIME, websocketUrl } from '../api/apiConstants';
|
import { RUNTIME, websocketUrl } from '../api/apiConstants';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
|
import { socketClientName } from '../stores/connectionName';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { runtime } from '../stores/runtime';
|
import { runtime } from '../stores/runtime';
|
||||||
|
|
||||||
@@ -11,13 +12,17 @@ const reconnectInterval = 1000;
|
|||||||
export let shouldReconnect = true;
|
export let shouldReconnect = true;
|
||||||
export let hasConnected = false;
|
export let hasConnected = false;
|
||||||
export let reconnectAttempts = 0;
|
export let reconnectAttempts = 0;
|
||||||
export const connectSocket = () => {
|
export const connectSocket = (preferredClientName?: string) => {
|
||||||
websocket = new WebSocket(websocketUrl);
|
websocket = new WebSocket(websocketUrl);
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
|
if (preferredClientName) {
|
||||||
|
socketSendJson('set-client-name', preferredClientName);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
@@ -49,6 +54,10 @@ export const connectSocket = () => {
|
|||||||
|
|
||||||
// TODO: implement partial store updates
|
// TODO: implement partial store updates
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'client-name': {
|
||||||
|
socketClientName.getState().setName(payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'ontime-log': {
|
case 'ontime-log': {
|
||||||
addLog(payload as Log);
|
addLog(payload as Log);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export class SocketServer implements IAdapter {
|
|||||||
this.wss = new WebSocketServer({ path: '/ws', server });
|
this.wss = new WebSocketServer({ path: '/ws', server });
|
||||||
|
|
||||||
this.wss.on('connection', (ws) => {
|
this.wss.on('connection', (ws) => {
|
||||||
const clientId = getRandomName();
|
let clientId = getRandomName();
|
||||||
this.clientIds.add(clientId);
|
this.clientIds.add(clientId);
|
||||||
logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`);
|
logger.info('CLIENT', `${this.wss.clients.size} Connections with new: ${clientId}`);
|
||||||
|
|
||||||
// send store payload on connect
|
// send store payload on connect
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -57,10 +57,17 @@ export class SocketServer implements IAdapter {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'client-name',
|
||||||
|
payload: clientId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
ws.on('error', console.error);
|
ws.on('error', console.error);
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
|
logger.info('CLIENT', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
|
||||||
this.clientIds.delete(clientId);
|
this.clientIds.delete(clientId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,20 +76,37 @@ export class SocketServer implements IAdapter {
|
|||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: protocol specific stuff should be handled here
|
|
||||||
// eg: rename-client
|
|
||||||
// socket.on('rename-client', (newName) => {
|
|
||||||
// if (newName) {
|
|
||||||
// const previousName = this._clientNames[socket.id];
|
|
||||||
// this._clientNames[socket.id] = newName;
|
|
||||||
// this.info('CLIENT', `Client ${previousName} renamed to ${newName}`);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data);
|
const message = JSON.parse(data);
|
||||||
const { type, payload } = message;
|
const { type, payload } = message;
|
||||||
|
|
||||||
|
if (type === 'get-client-name') {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'client-name',
|
||||||
|
payload: clientId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'set-client-name') {
|
||||||
|
if (payload) {
|
||||||
|
const previousName = clientId;
|
||||||
|
clientId = payload;
|
||||||
|
this.clientIds.delete(previousName);
|
||||||
|
this.clientIds.add(clientId);
|
||||||
|
logger.info('CLIENT', `Client ${previousName} renamed to ${clientId}`);
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'client-name',
|
||||||
|
payload: clientId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'hello') {
|
if (type === 'hello') {
|
||||||
ws.send('hi');
|
ws.send('hi');
|
||||||
return;
|
return;
|
||||||
@@ -95,6 +119,7 @@ export class SocketServer implements IAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protocol specific stuff handled above
|
||||||
try {
|
try {
|
||||||
const reply = dispatchFromAdapter(type, payload, 'ws');
|
const reply = dispatchFromAdapter(type, payload, 'ws');
|
||||||
if (reply) {
|
if (reply) {
|
||||||
|
|||||||
Reference in New Issue
Block a user