refactor: restructure settings

refactor: migrate react components
This commit is contained in:
Carlos Valente
2025-07-04 12:13:06 +02:00
parent a8ea1080f3
commit 5600235ba1
93 changed files with 711 additions and 906 deletions
@@ -1,19 +0,0 @@
.qrCode {
padding: 0.5rem;
background: $ui-white;
border-radius: 3px;
}
.column {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
user-select: text;
}
.copiableLink {
user-select: text;
color: $ui-white;
}
@@ -1,141 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormProps {
hostOptions: { value: string; label: string }[];
pathOptions: { value: string; label: string }[];
isLockedToView?: boolean;
}
interface GenerateLinkFormOptions {
baseUrl: string;
path: string;
lock: boolean;
authenticate: boolean;
}
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const {
handleSubmit,
setError,
watch,
setValue,
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: isLockedToView ? pathOptions[0].value : 'timer',
lock: false,
authenticate: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (options: GenerateLinkFormOptions) => {
try {
setFormState('loading');
const baseUrl = linkToOtherHost(options.baseUrl);
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
await copyToClipboard(url);
setUrl(url);
setFormState('success');
setTimeout(() => {
setFormState('pending');
}, 4000);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
setFormState('error');
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{!isLockedToView ? (
<Info>
<Panel.Paragraph>
You can generate a link to share with your team or to use in automation (such as companion).
</Panel.Paragraph>
</Info>
) : (
<Info>
<Panel.Paragraph>You can generate a link to share with your team</Panel.Paragraph>
</Info>
)}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select
disabled={isOntimeCloud}
options={hostOptions}
value={watch('baseUrl')}
onValueChange={(value) => setValue('baseUrl', value)}
/>
</Panel.ListItem>
{isLockedToView ? (
<input type='hidden' value={watch('path')} />
) : (
<Panel.ListItem>
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
<Select options={pathOptions} value={watch('path')} onValueChange={(value) => setValue('path', value)} />
</Panel.ListItem>
)}
<Panel.ListItem>
<Panel.Field
title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)'
/>
<Switch name='lock' checked={watch('lock')} onCheckedChange={(checked) => setValue('lock', checked)} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch
name='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} />
<div className={style.copiableLink}>{url}</div>
</div>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
);
}
@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
lockedPath?: { value: string; label: string };
}
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
const hostOptions = useMemo(
() =>
infoData.networkInterfaces.map((nif) => ({
value: nif.address,
label: `${nif.name} - ${nif.address}`,
})),
[infoData.networkInterfaces],
);
const pathOptions = useMemo(() => {
if (lockedPath) {
return [{ value: lockedPath.value, label: lockedPath.label }];
}
return [
{ value: 'timer', label: 'Timer' },
{ value: 'cuesheet', label: 'Cuesheet' },
{ value: 'op', label: 'Operator' },
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: preset.alias,
label: `Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
}
@@ -4,17 +4,14 @@ import { MessageTag } from 'ontime-types';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDockerImage, isOntimeCloud } from '../../../../externals';
import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import GenerateLinkFormExport from './GenerateLinkFormExport';
import InfoNif from './NetworkInterfaces';
import ClientControlPanel from './client-control/ClientControlPanel';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
@@ -26,21 +23,6 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<OntimeCloudStats />
</Panel.Section>
)}
<div ref={linkRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
{!isOntimeCloud && (
<>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</>
)}
<GenerateLinkFormExport />
</Panel.Card>
</Panel.Section>
</div>
<div ref={logRef}>
<LogExport />
</div>
@@ -0,0 +1,12 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.halfWidthNoWrap {
width: 50%;
white-space: nowrap;
}
@@ -0,0 +1,15 @@
import * as Panel from '../../../panel-utils/PanelUtils';
import ClientList from './ClientList';
export default function ClientControlPanel() {
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Manage clients</Panel.SubHeader>
<Panel.Divider />
<ClientList />
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,142 @@
import { useState } from 'react';
import { useDisclosure } from '@mantine/hooks';
import { Client } from 'ontime-types';
import Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import Tag from '../../../../../common/components/tag/Tag';
import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss';
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
const [isOpenRedirect, redirectHandler] = useDisclosure();
const [isOpenRename, renameHandler] = useDisclosure();
const { setIdentify } = setClientRemote;
const [targetId, setTargetId] = useState('');
const openRename = (targetId: string) => {
setTargetId(targetId);
renameHandler.open();
};
const openRedirect = (targetId: string) => {
setTargetId(targetId);
redirectHandler.open();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
const targetClient: Client | undefined = clients[targetId];
return (
<>
{isOpenRedirect && targetClient !== undefined && (
<RedirectClientModal
id={targetId}
name={targetClient.name}
origin={targetClient.origin}
currentPath={targetClient.path}
isOpen={isOpenRedirect}
onClose={redirectHandler.close}
/>
)}
{isOpenRename && (
<RenameClientModal
id={targetId}
name={targetClient?.name}
isOpen={isOpenRename}
onClose={renameHandler.close}
/>
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidth}>Client Name</td>
<td className={style.fullWidth}>Path</td>
<td />
</tr>
</thead>
<tbody>
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td>{path}</td>
<Panel.InlineElements relation='inner'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
}}
>
Identify
</Button>
<Button
size='small'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
Rename
</Button>
<Button
size='small'
disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
Redirect
</Button>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidthNoWrap}>Client Name</td>
<td className={style.halfWidthNoWrap}>Client type</td>
</tr>
</thead>
<tbody>
{otherClients.map(([key, client]) => {
const { name, type } = client;
return (
<tr key={key}>
<td>{name}</td>
<td>{type}</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</>
);
}