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,111 +0,0 @@
import { IoAdd } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const [deleteOpen, deleteHandlers] = useDisclosure();
const [loadOpen, loadHandlers] = useDisclosure();
return (
<>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Manage project rundowns
<Panel.InlineElements>
<Button onClick={() => undefined} disabled>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Table>
<thead>
<tr>
<th># Entries</th>
<th style={{ width: '100%' }}>Title</th>
<th />
</tr>
</thead>
<tbody>
{data.rundowns.map((rundown) => {
const isLoaded = data.loaded === rundown.id;
return (
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
<td>{rundown.numEntries}</td>
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => deleteHandlers.open()}
disabled={isLoaded}
>
Delete
</Button>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Card>
</Panel.Section>
<Dialog
isOpen={deleteOpen}
onClose={deleteHandlers.close}
title='Load rundown'
showBackdrop
showCloseButton
bodyElements={
<>
You will lose all data in your rundown. <br /> Are you sure?
</>
}
footerElements={
<>
<Button size='large' onClick={deleteHandlers.close}>
Cancel
</Button>
<Button variant='destructive' size='large' onClick={() => undefined}>
Delete rundown
</Button>
</>
}
/>
<Dialog
isOpen={loadOpen}
onClose={loadHandlers.close}
title='Delete rundown'
showBackdrop
showCloseButton
bodyElements={
<>
The current playback will be stopped. <br /> Are you sure?
</>
}
footerElements={
<>
<Button size='large' onClick={loadHandlers.close}>
Cancel
</Button>
<Button variant='primary' size='large' onClick={() => undefined}>
Load rundown
</Button>
</>
}
/>
</>
);
}
@@ -1,250 +0,0 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Textarea from '../../../../common/components/input/textarea/Textarea';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
export default function ProjectData() {
const { data, status, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { isSubmitting, isValid, isDirty, errors },
setError,
watch,
control,
setValue,
} = useForm({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// reset form values if data changes
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
validateLogo(file);
const response = await uploadProjectLogo(file);
setValue('projectLogo', response.data.logoFilename, {
shouldDirty: true,
});
} catch (error) {
const message = maybeAxiosError(error);
setError('projectLogo', { message });
}
};
const { ref, ...projectLogoRest } = register('projectLogo');
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const handleClickUpload = () => {
uploadInputRef.current?.click();
};
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setValue('projectLogo', null, {
shouldDirty: true,
});
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
// populate with new data if we get an update
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
<Panel.Card>
<Panel.SubHeader>
Project data
<Panel.InlineElements>
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<label>
Project title
<Input
fluid
maxLength={50}
placeholder='Project title is shown in production views'
{...register('title')}
/>
</label>
<Panel.Section style={{ marginTop: 0 }}>
<label>
Project logo
<input
type='file'
style={{ display: 'none' }}
accept='image/*'
{...projectLogoRest}
ref={(e) => {
ref(e);
uploadInputRef.current = e;
}}
onChange={handleUploadProjectLogo}
/>
<Panel.Card className={style.uploadLogoCard}>
{watch('projectLogo') ? (
<>
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
<Button
variant='subtle-destructive'
disabled={isSubmitting || !watch('projectLogo')}
onClick={handleDeleteLogo}
>
<IoTrash />
Delete
</Button>
</>
) : (
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
<IoDownloadOutline />
Upload logo
</Button>
)}
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
</Panel.Card>
</label>
</Panel.Section>
<label>
Project description
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} />
</label>
<label>
Backstage info
<Textarea
fluid
maxLength={150}
placeholder='Wi-Fi password: 1234'
resize='vertical'
{...register('backstageInfo')}
/>
</label>
<label>
Backstage QR code URL
<Input fluid placeholder={documentationUrl} {...register('backstageUrl')} />
</label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button onClick={handleAddCustom}>
Add <IoAdd />
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div className={style.titleRow}>
<label>
Title
<Input
fluid
defaultValue={field.title}
placeholder='Title of your custom data'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button variant='subtle-destructive' onClick={() => remove(idx)}>
<IoTrash />
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
<label>
Value
<Textarea
fluid
rows={3}
resize='vertical'
defaultValue={field.value}
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -45,21 +46,16 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
<Input
className={style.formInput}
id='filename'
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<Panel.InlineElements relation='inner'>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
<Button onClick={onCancel} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
variant='primary'
disabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -8,7 +9,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
export default function ProjectList() {
const { data, refetch } = useOrderedProjectList();
const { data, refetch, status } = useOrderedProjectList();
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
@@ -27,30 +28,47 @@ export default function ProjectList() {
await refetch();
};
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length;
return (
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>File Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<tbody>
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
<>
{numProjects > 20 && (
<Info className={style.warningInfo} type='warning'>
You have {numProjects} projects. Consider deleting unused projects to improve performance.
</Info>
)}
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>File Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<tbody>
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
</>
);
}
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Switch } from '@chakra-ui/react';
import { Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -76,16 +77,10 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Panel.Title>
Merge {`"${fileName}"`}
<Panel.InlineElements>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</Panel.InlineElements>
@@ -45,36 +45,11 @@
}
}
.uploadLogoCard {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: 3px;
img {
max-width: 250px;
height: auto;
}
.warningInfo {
margin-bottom: 1rem;
}
.customDataItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
.empty {
height: 300px;
position: relative;
}
@@ -5,16 +5,12 @@ import QuickStart from '../../quick-start/QuickStart';
import type { SettingsOptionId } from '../../useAppSettingsMenu';
import ManageProjects from './ManageProjects';
import ManageRundowns from './ManageRundowns';
import ProjectData from './ProjectData';
interface ProjectPanelProps extends PanelBaseProps {
setLocation: (location: SettingsOptionId) => void;
}
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location);
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location);
const handleQuickClose = () => {
@@ -25,12 +21,6 @@ export default function ProjectPanel({ location, setLocation }: ProjectPanelProp
<>
<Panel.Header>Project</Panel.Header>
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
<div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRundownsRef}>
<ManageRundowns />
</div>
<div ref={manageProjectsRef}>
<ManageProjects />
</div>