mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 12:29:10 +00:00
chore: migrate remaining settings (#799)
* chore: migrate remaining settings --------- Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
$loader-size: 4rem;
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
background-color: $black-10;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: $loader-size;
|
||||||
|
height: $loader-size;
|
||||||
|
background: $blue-500;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: animloader 1s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animloader {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animloader {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import style from './LoaderOverlay.module.scss';
|
||||||
|
|
||||||
|
export default function LoaderOverlay() {
|
||||||
|
return (
|
||||||
|
<div className={style.overlay}>
|
||||||
|
<span className={style.loader} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,5 +17,5 @@ export default function useInfo() {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, status, isError, refetch, isFetching };
|
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ $inner-padding: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
position: relative;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
font-size: calc(1rem - 1px);
|
font-size: calc(1rem - 1px);
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
@@ -128,3 +129,49 @@ $inner-padding: 1rem;
|
|||||||
border-top: 1px solid $white-10;
|
border-top: 1px solid $white-10;
|
||||||
margin: 1rem -2rem;
|
margin: 1rem -2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$loader-size: 4rem;
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
left: 1rem;
|
||||||
|
height: calc(100% - 1rem);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
background-color: rgba(0, 0, 0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: $loader-size;
|
||||||
|
height: $loader-size;
|
||||||
|
background: $blue-500;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: animloader 1s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animloader {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animloader {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,11 @@ export function Error({ children }: { children: ReactNode }) {
|
|||||||
export function Divider() {
|
export function Divider() {
|
||||||
return <hr className={style.divider} />;
|
return <hr className={style.divider} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Loader() {
|
||||||
|
return (
|
||||||
|
<div className={style.overlay}>
|
||||||
|
<span className={style.loader} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
export default function EditorSettingsForm() {
|
||||||
|
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||||
|
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
|
||||||
|
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
|
||||||
|
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Editor settings</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Show quick entry'
|
||||||
|
description='Whether the quick entry buttons show under selected event'
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
variant='ontime'
|
||||||
|
size='lg'
|
||||||
|
defaultChecked={eventSettings.showQuickEntry}
|
||||||
|
onChange={(event) => setShowQuickEntry(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Start time is last end'
|
||||||
|
description='New events start time will be the previous event end'
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
variant='ontime'
|
||||||
|
size='lg'
|
||||||
|
defaultChecked={eventSettings.startTimeIsLastEnd}
|
||||||
|
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Default public' description='New events will be public' />
|
||||||
|
<Switch
|
||||||
|
variant='ontime'
|
||||||
|
size='lg'
|
||||||
|
defaultChecked={eventSettings.defaultPublic}
|
||||||
|
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import EditorSettingsForm from './EditorSettingsForm';
|
||||||
import GeneralPanelForm from './GeneralPanelForm';
|
import GeneralPanelForm from './GeneralPanelForm';
|
||||||
|
import ViewSettingsForm from './ViewSettingsForm';
|
||||||
|
|
||||||
export default function GeneralPanel() {
|
export default function GeneralPanel() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Settings</Panel.Header>
|
<Panel.Header>Settings</Panel.Header>
|
||||||
<GeneralPanelForm />
|
<GeneralPanelForm />
|
||||||
|
<EditorSettingsForm />
|
||||||
|
<ViewSettingsForm />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function GeneralPanelForm() {
|
|||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
General settings
|
General settings
|
||||||
<div className={style.actionButtons}>
|
<div className={style.actionButtons}>
|
||||||
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghost' size='sm' onClick={onReset}>
|
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
|
||||||
Revert to saved
|
Revert to saved
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
||||||
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||||
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
|
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||||
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
|
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import style from './GeneralPanel.module.scss';
|
||||||
|
|
||||||
|
const cssOverrideDocsUrl = 'https://ontime.gitbook.io/v2/features/custom-styling';
|
||||||
|
|
||||||
|
export default function ViewSettingsForm() {
|
||||||
|
const { data, status, refetch, isFetching } = useViewSettings();
|
||||||
|
const { data: info, isFetching: isFetchingInfo } = useInfo();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
formState: { isSubmitting, isDirty },
|
||||||
|
} = useForm<ViewSettings>({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update form if we get new data from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
reset(data);
|
||||||
|
}
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: ViewSettings) => {
|
||||||
|
const newData = {
|
||||||
|
...formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postViewSettings(newData);
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('root', { message });
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!control) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableInputs = status === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
View settings
|
||||||
|
<div className={style.actionButtons}>
|
||||||
|
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
|
||||||
|
Revert to saved
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
{isFetching || isFetchingInfo ? <Panel.Loader /> : null}
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertDescription>
|
||||||
|
You can override the styles of the viewers with a custom CSS file. <br />
|
||||||
|
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Override CSS styles'
|
||||||
|
description='Enables overriding view styles with custom stylesheet'
|
||||||
|
/>
|
||||||
|
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
||||||
|
<PopoverPickerRHF name='normalColor' control={control} />
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
||||||
|
<PopoverPickerRHF name='warningColor' control={control} />
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
||||||
|
<PopoverPickerRHF name='dangerColor' control={control} />
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
|
||||||
|
<Input
|
||||||
|
size='sm'
|
||||||
|
autoComplete='off'
|
||||||
|
variant='ontime-filled'
|
||||||
|
maxLength={150}
|
||||||
|
width='275px'
|
||||||
|
placeholder='Message shown when timer reaches end'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('endMessage')}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
|
||||||
|
import { uploadProjectFile } from '../../../../common/api/db';
|
||||||
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import ProjectCreateForm from './ProjectCreateForm';
|
||||||
|
import ProjectList from './ProjectList';
|
||||||
|
|
||||||
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
|
export default function ManageProjects() {
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState<'import' | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleToggleCreate = () => {
|
||||||
|
setIsCreatingProject((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFile = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = event.target?.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading('import');
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateProjectFile(selectedFile);
|
||||||
|
await uploadProjectFile(selectedFile);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = maybeAxiosError(error);
|
||||||
|
setError(`Error uploading file: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
invalidateAllCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsCreatingProject(false);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleImport}
|
||||||
|
accept='.json'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Manage projects
|
||||||
|
<div className={style.headerButtons}>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
onClick={handleSelectFile}
|
||||||
|
size='sm'
|
||||||
|
isDisabled={Boolean(loading) || isCreatingProject}
|
||||||
|
isLoading={loading === 'import'}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
onClick={handleToggleCreate}
|
||||||
|
size='sm'
|
||||||
|
isDisabled={Boolean(loading) || isCreatingProject}
|
||||||
|
rightIcon={<IoAdd />}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
|
<Panel.Divider />
|
||||||
|
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
|
||||||
|
<ProjectList />
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
|
import { type ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postProjectData } from '../../../../common/api/project';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
|
export default function ProjectData() {
|
||||||
|
const { data, refetch } = useProjectData();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting, isValid, isDirty },
|
||||||
|
setError,
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset form values if data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
reset(data);
|
||||||
|
}
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
|
try {
|
||||||
|
await postProjectData(formData);
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('root', { message });
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Project Data
|
||||||
|
<div className={style.headerButtons}>
|
||||||
|
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
|
||||||
|
Revert to saved
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
type='submit'
|
||||||
|
isDisabled={!isDirty || !isValid}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Project title
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
maxLength={50}
|
||||||
|
placeholder='Your project name'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Project description
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
maxLength={100}
|
||||||
|
placeholder='Euro Love, Malmö 2024'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Public info
|
||||||
|
<Textarea
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
maxLength={150}
|
||||||
|
placeholder='Shows always start ontime'
|
||||||
|
autoComplete='off'
|
||||||
|
resize='none'
|
||||||
|
{...register('publicInfo')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Public QR code URL
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder='www.getontime.no'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('publicUrl')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Backstage info
|
||||||
|
<Textarea
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
maxLength={150}
|
||||||
|
placeholder='Wi-Fi password: 1234'
|
||||||
|
autoComplete='off'
|
||||||
|
resize='none'
|
||||||
|
{...register('backstageInfo')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.createFormInputField}>
|
||||||
|
<label>
|
||||||
|
Backstage QR code URL
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder='www.ontime.gitbook.io'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('backstageUrl')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,98 +1,14 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
|
|
||||||
import { uploadProjectFile } from '../../../../common/api/db';
|
|
||||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
|
||||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import ProjectCreateForm from './ProjectCreateForm';
|
import ManageProjects from './ManageProjects';
|
||||||
import ProjectList from './ProjectList';
|
import ProjectData from './ProjectData';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
|
||||||
|
|
||||||
export default function ProjectPanel() {
|
export default function ProjectPanel() {
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState<'import' | null>(null);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleToggleCreate = () => {
|
|
||||||
setIsCreatingProject((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectFile = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const selectedFile = event.target?.files?.[0];
|
|
||||||
if (!selectedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading('import');
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateProjectFile(selectedFile);
|
|
||||||
await uploadProjectFile(selectedFile);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = maybeAxiosError(error);
|
|
||||||
setError(`Error uploading file: ${errorMessage}`);
|
|
||||||
} finally {
|
|
||||||
invalidateAllCaches();
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setIsCreatingProject(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Project</Panel.Header>
|
<Panel.Header>Project</Panel.Header>
|
||||||
<Input
|
<ProjectData />
|
||||||
ref={fileInputRef}
|
<ManageProjects />
|
||||||
style={{ display: 'none' }}
|
|
||||||
type='file'
|
|
||||||
onChange={handleImport}
|
|
||||||
accept='.json'
|
|
||||||
data-testid='file-input'
|
|
||||||
/>
|
|
||||||
<Panel.Section>
|
|
||||||
<Panel.Card>
|
|
||||||
<Panel.SubHeader>
|
|
||||||
Manage projects
|
|
||||||
<div className={style.headerButtons}>
|
|
||||||
<Button
|
|
||||||
variant='ontime-subtle'
|
|
||||||
onClick={handleSelectFile}
|
|
||||||
size='sm'
|
|
||||||
isDisabled={Boolean(loading) || isCreatingProject}
|
|
||||||
isLoading={loading === 'import'}
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='ontime-subtle'
|
|
||||||
onClick={handleToggleCreate}
|
|
||||||
size='sm'
|
|
||||||
isDisabled={Boolean(loading) || isCreatingProject}
|
|
||||||
rightIcon={<IoAdd />}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Panel.SubHeader>
|
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
|
||||||
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
|
|
||||||
<ProjectList />
|
|
||||||
</Panel.Card>
|
|
||||||
</Panel.Section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Switch } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
|
||||||
import ModalSplitInput from '../ModalSplitInput';
|
|
||||||
|
|
||||||
import style from './SettingsModal.module.scss';
|
|
||||||
|
|
||||||
export default function EditorSettings() {
|
|
||||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
|
||||||
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
|
|
||||||
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
|
|
||||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.sectionContainer}>
|
|
||||||
<span className={style.title}>Rundown settings</span>
|
|
||||||
<ModalSplitInput field='' title='Show quick entry' description='Whether quick entry shows under selected event'>
|
|
||||||
<Switch
|
|
||||||
variant='ontime-on-light'
|
|
||||||
defaultChecked={eventSettings.showQuickEntry}
|
|
||||||
onChange={(event) => setShowQuickEntry(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</ModalSplitInput>
|
|
||||||
<ModalSplitInput
|
|
||||||
field=''
|
|
||||||
title='Start time is last end'
|
|
||||||
description='New events start time will be previous event end'
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
variant='ontime-on-light'
|
|
||||||
defaultChecked={eventSettings.startTimeIsLastEnd}
|
|
||||||
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</ModalSplitInput>
|
|
||||||
<ModalSplitInput field='' title='Default public' description='New events will be public'>
|
|
||||||
<Switch
|
|
||||||
variant='ontime-on-light'
|
|
||||||
defaultChecked={eventSettings.defaultPublic}
|
|
||||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</ModalSplitInput>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { UseFormRegister } from 'react-hook-form';
|
|
||||||
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
|
||||||
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
|
||||||
|
|
||||||
import { enDash } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
interface FormInput {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModalPinInputProps {
|
|
||||||
register: UseFormRegister<FormInput>;
|
|
||||||
formName: string;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModalPinInput({ register, formName, isDisabled }: ModalPinInputProps) {
|
|
||||||
const [isVisible, setVisible] = useState(false);
|
|
||||||
return (
|
|
||||||
<InputGroup size='sm' width='100px'>
|
|
||||||
<Input
|
|
||||||
type={isVisible ? 'text' : 'password'}
|
|
||||||
maxLength={4}
|
|
||||||
{...register(formName)}
|
|
||||||
placeholder={enDash}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
/>
|
|
||||||
<InputRightElement>
|
|
||||||
<IconButton
|
|
||||||
onMouseDown={() => setVisible(true)}
|
|
||||||
onMouseUp={() => setVisible(false)}
|
|
||||||
size='sm'
|
|
||||||
variant='ontime-ghost-on-light'
|
|
||||||
icon={<IoEyeOutline />}
|
|
||||||
aria-label='Show pin code'
|
|
||||||
/>
|
|
||||||
</InputRightElement>
|
|
||||||
</InputGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r
|
|||||||
import ModalWrapper from '../ModalWrapper';
|
import ModalWrapper from '../ModalWrapper';
|
||||||
|
|
||||||
import AliasesForm from './AliasesForm';
|
import AliasesForm from './AliasesForm';
|
||||||
import EditorSettings from './EditorSettings';
|
|
||||||
import ProjectDataForm from './ProjectDataForm';
|
import ProjectDataForm from './ProjectDataForm';
|
||||||
import ViewSettingsForm from './ViewSettingsForm';
|
|
||||||
|
|
||||||
interface ModalManagerProps {
|
interface ModalManagerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -20,20 +18,12 @@ export default function SettingsModal(props: ModalManagerProps) {
|
|||||||
<Tabs variant='ontime' size='sm' isLazy>
|
<Tabs variant='ontime' size='sm' isLazy>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>Project Data</Tab>
|
<Tab>Project Data</Tab>
|
||||||
<Tab>Editor</Tab>
|
|
||||||
<Tab>Views</Tab>
|
|
||||||
<Tab>URL Aliases</Tab>
|
<Tab>URL Aliases</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<ProjectDataForm />
|
<ProjectDataForm />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
|
||||||
<EditorSettings />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<ViewSettingsForm />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<AliasesForm />
|
<AliasesForm />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input, Switch } from '@chakra-ui/react';
|
|
||||||
import { ViewSettings } from 'ontime-types';
|
|
||||||
|
|
||||||
import { logAxiosError } from '../../../common/api/utils';
|
|
||||||
import { postViewSettings } from '../../../common/api/viewSettings';
|
|
||||||
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
|
|
||||||
import useInfo from '../../../common/hooks-query/useInfo';
|
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
|
||||||
import { inputProps } from '../modalHelper';
|
|
||||||
import ModalInput from '../ModalInput';
|
|
||||||
import ModalLink from '../ModalLink';
|
|
||||||
import ModalSplitInput from '../ModalSplitInput';
|
|
||||||
import OntimeModalFooter from '../OntimeModalFooter';
|
|
||||||
|
|
||||||
import style from './SettingsModal.module.scss';
|
|
||||||
|
|
||||||
const cssOverrideDocsUrl = 'https://ontime.gitbook.io/v2/features/custom-styling';
|
|
||||||
|
|
||||||
export default function ViewSettingsForm() {
|
|
||||||
const { data, status, refetch, isFetching } = useViewSettings();
|
|
||||||
const { data: info, isFetching: isFetchingInfo } = useInfo();
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
register,
|
|
||||||
reset,
|
|
||||||
formState: { isSubmitting, isDirty, isValid },
|
|
||||||
} = useForm<ViewSettings>({
|
|
||||||
defaultValues: data,
|
|
||||||
values: data,
|
|
||||||
resetOptions: {
|
|
||||||
keepDirtyValues: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
reset(data);
|
|
||||||
}
|
|
||||||
}, [data, reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: ViewSettings) => {
|
|
||||||
const newData = {
|
|
||||||
...formData,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await postViewSettings(newData);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error saving view settings', error);
|
|
||||||
} finally {
|
|
||||||
await refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReset = () => {
|
|
||||||
reset(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!control) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const disableInputs = status === 'pending';
|
|
||||||
|
|
||||||
if (isFetching || isFetchingInfo) {
|
|
||||||
return <ModalLoader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
|
|
||||||
<span className={style.title}>General view settings</span>
|
|
||||||
<Alert status='info' variant='ontime-on-light-info'>
|
|
||||||
<AlertIcon />
|
|
||||||
<div className={style.column}>
|
|
||||||
<AlertTitle>CSS Override</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Ontime will use the CSS file at its install location. <br />
|
|
||||||
<span className={style.url}>{info?.cssOverride}</span>
|
|
||||||
<ModalLink href={cssOverrideDocsUrl}>For more information, see the docs</ModalLink>
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
<ModalSplitInput
|
|
||||||
field='overrideStyles'
|
|
||||||
title='Override CSS Styles'
|
|
||||||
description='Enables overriding view styles with custom stylesheet'
|
|
||||||
>
|
|
||||||
<Switch {...register('overrideStyles')} variant='ontime-on-light' />
|
|
||||||
</ModalSplitInput>
|
|
||||||
<span className={style.title}>Timer view settings</span>
|
|
||||||
<ModalSplitInput field='normalColor' title='Timer color' description='Normal color of a running timer'>
|
|
||||||
<PopoverPickerRHF name='normalColor' control={control} />
|
|
||||||
</ModalSplitInput>
|
|
||||||
<ModalSplitInput field='warningColor' title='Warning color' description='Color of timer in warning mode'>
|
|
||||||
<PopoverPickerRHF name='warningColor' control={control} />
|
|
||||||
</ModalSplitInput>
|
|
||||||
<ModalSplitInput field='dangerColor' title='Timer color' description='Color of timer in danger mode'>
|
|
||||||
<PopoverPickerRHF name='dangerColor' control={control} />
|
|
||||||
</ModalSplitInput>
|
|
||||||
<div style={{ height: '16px' }} />
|
|
||||||
<ModalInput
|
|
||||||
field='endMessage'
|
|
||||||
title='End Message'
|
|
||||||
description='If no end message is provided, timer will continue in overtime mode'
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
variant='ontime-filled-on-light'
|
|
||||||
maxLength={150}
|
|
||||||
placeholder='Message to be shown when timer reaches end'
|
|
||||||
isDisabled={disableInputs}
|
|
||||||
{...register('endMessage')}
|
|
||||||
/>
|
|
||||||
</ModalInput>
|
|
||||||
<OntimeModalFooter
|
|
||||||
formId='view-settings'
|
|
||||||
handleRevert={onReset}
|
|
||||||
isDirty={isDirty}
|
|
||||||
isValid={isValid}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,7 @@ export async function postViewSettings(req: Request, res: Response<ViewSettings
|
|||||||
endMessage: req.body?.endMessage || '',
|
endMessage: req.body?.endMessage || '',
|
||||||
normalColor: req.body.normalColor,
|
normalColor: req.body.normalColor,
|
||||||
warningColor: req.body.warningColor,
|
warningColor: req.body.warningColor,
|
||||||
warningThreshold: req.body.warningThreshold,
|
|
||||||
dangerColor: req.body.dangerColor,
|
dangerColor: req.body.dangerColor,
|
||||||
dangerThreshold: req.body.dangerThreshold,
|
|
||||||
};
|
};
|
||||||
await DataProvider.setViewSettings(newData);
|
await DataProvider.setViewSettings(newData);
|
||||||
res.status(200).send(newData);
|
res.status(200).send(newData);
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export const validateViewSettings = [
|
|||||||
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
|
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
|
||||||
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
|
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
|
||||||
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
|
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
|
||||||
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
|
|
||||||
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
|||||||
Reference in New Issue
Block a user