feat: cuesheet sharing

feat: locked presets

refactor: simplify locked param

refactor: create share links
This commit is contained in:
Carlos Valente
2025-07-22 05:45:20 +02:00
committed by Carlos Valente
parent 1695b4dc68
commit 6c6f5c2c0f
62 changed files with 1661 additions and 478 deletions
@@ -16,4 +16,16 @@
.copiableLink {
user-select: text;
color: $ui-white;
}
white-space: nowrap;
overflow-x: auto;
}
.shareInline {
display: grid;
grid-template-columns: 1fr 172px;
gap: 1rem;
}
.end {
padding-right: 2rem;
}
@@ -1,70 +1,157 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { generateUrl } from '../../common/api/session';
import { maybeAxiosError } from '../../common/api/utils';
import Button from '../../common/components/buttons/Button';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import Info from '../../common/components/info/Info';
import Input from '../../common/components/input/input/Input';
import Select from '../../common/components/select/Select';
import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
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 { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormProps {
hostOptions: { value: string; label: string }[];
pathOptions: { value: string; label: string }[];
pathOptions: { value: OntimeView | string; label: string }[];
presets: URLPreset[];
isLockedToView?: boolean;
}
interface GenerateLinkFormOptions {
type GenericLinkOptions = {
baseUrl: string;
path: string;
lock: boolean;
path: OntimeView | string; // we use empty string for Companion view
authenticate: boolean;
}
lockConfig: boolean;
lockNav: boolean;
};
type CuesheetLinkOptions = GenericLinkOptions & {
path: OntimeView.Cuesheet;
alias: string;
options: {
read?: string;
write?: string;
};
};
type GenerateLinkFormOptions = GenericLinkOptions | CuesheetLinkOptions;
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const cuesheetReadRef = useRef<HTMLInputElement>(null);
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
const { addPreset } = useUpdateUrlPreset();
const {
handleSubmit,
setError,
watch,
setValue,
formState: { errors },
reset,
register,
formState: { errors, isDirty },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: isLockedToView ? pathOptions[0].value : 'timer',
lock: false,
baseUrl: serverURL,
path: isLockedToView ? pathOptions[0].value : OntimeView.Timer,
authenticate: false,
},
resetOptions: {
keepDirtyValues: true,
lockConfig: false,
lockNav: false,
},
});
/**
* If the user is generating a link to the cuesheet we gather extra options
* The extra options are saved into a URL preset which we then request a share link for
*/
const createPresetFromOptions = async (
alias: string,
options: Required<CuesheetLinkOptions['options']>,
): Promise<URLPreset | undefined> => {
if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions');
}
const presets = await addPreset({
target: OntimeView.Cuesheet,
enabled: true,
alias,
search: '',
options: {
read: options.read,
write: options.write,
},
});
return presets.find((preset) => preset.alias === alias);
};
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);
if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
read: cuesheetReadRef.current?.value ?? 'full',
write: cuesheetWriteRef.current?.value ?? 'full',
});
if (!urlPreset) {
throw new Error('Failed to create URL preset for Cuesheet');
}
const url = await generateUrl({
baseUrl: options.baseUrl,
path: options.path,
authenticate: options.authenticate,
lockConfig: options.lockConfig,
lockNav: options.lockNav,
preset: urlPreset.alias,
});
await copyToClipboard(url);
setUrl(url);
} else {
const presetPath = options.path.startsWith('preset-') ? options.path.replace('preset-', '') : undefined;
const path = presetPath ? presets.find((preset) => preset.alias === presetPath)?.target : options.path;
if (!path) {
throw new Error(`Could not resolve preset: ${path}`);
}
const url = await generateUrl({
baseUrl: linkToOtherHost(options.baseUrl),
path,
authenticate: options.authenticate,
lockConfig: options.lockConfig,
lockNav: options.lockNav,
preset: presetPath,
});
await copyToClipboard(url);
setUrl(url);
}
reset(options, {
keepValues: true,
keepDirty: false,
});
setFormState('success');
setTimeout(() => {
setFormState('pending');
}, 4000);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
@@ -72,70 +159,113 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToV
}
};
const canSubmit = isDirty || formState !== 'success';
return (
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{!isLockedToView ? (
{!isLockedToView && (
<Info>You can generate a link to share with your team or to use in automation (such as companion).</Info>
) : (
<Info>You can generate a link to share with your team</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>
)}
<div className={style.shareInline}>
<div className={style.column}>
<Panel.ListGroup>
{isOntimeCloud ? (
<input hidden readOnly name='baseUrl' value={serverURL} />
) : (
<Panel.ListItem>
<Panel.Field
title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select
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, { shouldDirty: true })}
/>
</Panel.ListItem>
)}
<Panel.ListItem>
<Panel.Field
title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)'
/>
<Switch
size='large'
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
size='large'
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>
{watch('path') === OntimeView.Cuesheet && (
<>
<Panel.ListItem>
<Panel.Field
title='Preset alias'
description='The name of the preset we will create to hold this options'
error={(errors as FieldErrors<CuesheetLinkOptions>).alias?.message}
/>
<Input
defaultValue={generatedAlias.current}
{...register('alias', {
required: 'Alias cannot be empty and must be unique',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</Panel.ListItem>
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
</>
)}
<Panel.ListItem>
<Panel.Field title='Lock navigation' description='Whether to hide the navigation menu' />
<Switch
size='large'
name='lockNav'
checked={watch('lockNav')}
onCheckedChange={(checked) => setValue('lockNav', checked, { shouldDirty: true })}
/>
</Panel.ListItem>
{watch('path') !== OntimeView.Cuesheet && (
<Panel.ListItem>
<Panel.Field title='Lock configuration' description='Whether to hide the configuration panel' />
<Switch
size='large'
name='lockConfig'
checked={watch('lockConfig')}
onCheckedChange={(checked) => setValue('lockConfig', checked, { shouldDirty: true })}
/>
</Panel.ListItem>
)}
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch
size='large'
name='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked, { shouldDirty: true })}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end' className={style.end}>
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
</Button>
</Panel.InlineElements>
</div>
<Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description>
<QRCode size={172} value={url} className={style.qrCode} />
<div className={style.copiableLink} data-testid='copy-link'>
{url}
</div>
</Panel.ListItem>
</Panel.ListGroup>
<CopyTag copyValue={url}>Copy link</CopyTag>
</Panel.Section>
</div>
</form>
);
}
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { OntimeView } from 'ontime-types';
import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets';
@@ -6,37 +7,42 @@ import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
lockedPath?: { value: string; label: string };
lockedPath?: { value: OntimeView; 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 hostOptions = useMemo(() => {
return 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: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: preset.alias,
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
return (
<GenerateLinkForm
hostOptions={hostOptions}
pathOptions={pathOptions}
presets={urlPresetData}
isLockedToView={Boolean(lockedPath)}
/>
);
}
@@ -0,0 +1,18 @@
.twoCols {
display: grid;
grid-template-columns: max-content max-content;
column-gap: 3rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, max-content);
column-gap: 1rem;
row-gap: 0.5rem;
align-content: start;
}
.inline {
display: flex;
gap: 0.5rem;
}
@@ -0,0 +1,187 @@
import { Fragment, RefObject, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { cuesheetDefaultColumns, makeCuesheetCustomColumns } from '../../../views/cuesheet/cuesheet.options';
import * as Panel from '../../app-settings/panel-utils/PanelUtils';
import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom';
interface CuesheetLinkOptionsProps {
readRef?: RefObject<HTMLInputElement | null>;
writeRef?: RefObject<HTMLInputElement | null>;
}
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
});
return initialState;
});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
});
return initialState;
});
const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
Object.keys(updatedReadSwitches).forEach((key) => {
updatedReadSwitches[key] = true;
});
return updatedReadSwitches;
});
};
const handleWriteModeChange = (value: AccessMode) => {
if (value === 'full') {
setReadPermissions('full');
}
setWritePermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches };
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
updatedReadSwitches[column.value] = true;
updatedWriteSwitches[column.value] = true;
});
return updatedWriteSwitches;
});
return updatedReadSwitches;
});
};
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
if (type === 'read') {
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
return updatedReadSwitches;
});
} else {
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
return updatedWriteSwitches;
});
}
};
const getReadPermissions = () => {
if (readPermissions === 'full' || writePermissions === 'full') {
return 'full';
}
return Object.entries(readSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
const getWritePermissions = () => {
if (writePermissions === 'full') {
return 'full';
}
return Object.entries(writeSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
return (
<Panel.Indent>
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
<div>
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
<div>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
<RadioGroup
value={readPermissions}
onValueChange={handleReadModeChange}
orientation='horizontal'
disabled={writePermissions === 'full'}
items={[
{ value: 'full', label: 'Full read (view all existing and future columns)' },
{ value: 'custom', label: 'Custom read' },
]}
/>
</div>
</div>
<div className={style.twoCols}>
<div className={style.grid}>
<Panel.Description>Ontime columns</Panel.Description>
<Panel.Description>Read</Panel.Description>
<Panel.Description>Write</Panel.Description>
{cuesheetDefaultColumns.map((column) => (
<Fragment key={column.value}>
<div>{column.label}</div>
<Switch
checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
<Switch
checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
</Fragment>
))}
</div>
{customFieldColumns.length > 0 && (
<div className={style.grid}>
<Panel.Description>Custom fields</Panel.Description>
<Panel.Description>Read</Panel.Description>
<Panel.Description>Write</Panel.Description>
{customFieldColumns.map((column) => (
<Fragment key={column.value}>
{column.label}
<Switch
checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
<Switch
checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
</Fragment>
))}
</div>
)}
</div>
</Panel.Indent>
);
}