feat: implement custom project data image

This commit is contained in:
Carlos Valente
2025-07-11 18:42:09 +02:00
parent d8c1c38123
commit 138c6523d2
13 changed files with 130 additions and 116 deletions
@@ -10,6 +10,7 @@
align-items: center;
flex-direction: row;
gap: 1rem;
width: 100%;
}
.vertical {
@@ -21,6 +22,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
height: 2rem;
&:has([data-checked]) {
color: $ui-white;
+7 -4
View File
@@ -10,7 +10,7 @@ import {
} from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, PROJECT_DATA, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
import {
@@ -155,6 +155,12 @@ export const connectSocket = () => {
case RefetchKey.CustomFields:
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
break;
case RefetchKey.ProjectData:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
break;
case RefetchKey.Report:
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
case RefetchKey.Rundown:
if (revision === (ontimeQueryClient.getQueryData(RUNDOWN) as Rundown).revision) break;
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -163,9 +169,6 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.Report:
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
default: {
target satisfies never;
break;
@@ -1,16 +1,11 @@
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
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 { preventEscape } from '../../../../common/utils/keyEvent';
import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -31,12 +26,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { onClose } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
control,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
@@ -47,11 +40,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field
useEffect(() => {
setFocus('title');
@@ -63,22 +51,14 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const filename = values.title ?? 'untitled';
await createProject({
...values,
filename,
});
await createProject({ filename });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return (
<Panel.Section
as='form'
@@ -100,44 +80,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input fluid maxLength={50} placeholder='Your project name' {...register('title')} />
<Input fluid placeholder='Your project name' {...register('title')} />
</label>
<label>
Project description
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} />
</label>
<label>
Project info
<Textarea fluid maxLength={150} placeholder='Wi-Fi password: 1234' resize='vertical' {...register('info')} />
</label>
<label>
Project QR code URL
<Input fluid placeholder={documentationUrl} {...register('url')} />
</label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button onClick={handleAddCustom}>
Add <IoAdd />
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input placeholder={field.title} {...register(`custom.${idx}.title` as const)} />
</label>
<label>
Value
<Input placeholder={field.value} autoComplete='off' {...register(`custom.${idx}.value` as const)} />
</label>
<Button variant='ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section>
</Panel.Section>
);
@@ -87,7 +87,7 @@ export default function ProjectData() {
};
const handleAddCustom = () => {
append({ title: '', value: '' });
append({ title: '', value: '', url: '' });
};
const onSubmit = async (formData: ProjectData) => {
@@ -208,7 +208,7 @@ export default function ProjectData() {
return (
<div key={field.id} className={style.customDataItem}>
<div className={style.titleRow}>
<label>
<label className={style.title}>
Title
<Input
fluid
@@ -221,12 +221,12 @@ export default function ProjectData() {
</label>
<Button variant='subtle-destructive' onClick={() => remove(idx)}>
<IoTrash />
Delete Entry
Delete
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
<label>
Value
Text
<Textarea
fluid
rows={3}
@@ -239,6 +239,20 @@ export default function ProjectData() {
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
<label>
Image URL (optional)
<div className={style.customImage}>
<Input
fluid
defaultValue={field.value}
placeholder='Paste image URL (optional)'
{...register(`custom.${idx}.url`)}
/>
<div className={style.imageContainer}>
<img src={watch(`custom.${idx}.url`)} alt='' loading='lazy' className='info__image' />
</div>
</div>
</label>
</div>
);
})}
@@ -31,3 +31,15 @@
flex: 1;
}
}
.customImage {
display: grid;
grid-template-columns: 1fr 72px;
gap: 1rem;
}
.imageContainer {
width: 72px;
height: 72px;
background-color: $gray-1250;
}
@@ -15,7 +15,6 @@
display: flex;
flex-direction: column;
align-items: center;
.logo {
max-width: min(200px, 30vw);
@@ -24,8 +23,13 @@
.info {
flex: 1;
max-height: 100%;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-bottom: 10vh;
}
@@ -34,19 +38,37 @@
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
margin-top: $view-element-gap;
}
.info__value {
white-space: break-spaces;
}
.info__custom {
display: flex;
gap: 1rem;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
}
.info__image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.link.info__value {
display: flex;
gap: 0.5rem;
gap: $view-element-gap;
align-items: center;
color: $action-text-color;
&:hover {
color: $ontime-color;
}
@@ -59,5 +81,9 @@
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,4 +1,3 @@
import { Fragment } from 'react/jsx-runtime';
import { IoOpenOutline } from 'react-icons/io5';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
@@ -52,40 +51,45 @@ export default function ProjectInfo() {
{data.logo && <ViewLogo name={data.logo} className='logo' />}
<div className='info'>
{data.title && (
<>
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{data.title}</div>
</>
</div>
)}
{data.description && (
<>
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{data.description}</div>
</>
</div>
)}
{data.info && (
<>
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{data.info}</div>
</>
</div>
)}
{data.url && (
<>
<div>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={data.url} target='_blank' rel='noreferrer' className='info__value link'>
{data.url} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</>
</div>
)}
{data.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
const hasUrl = Boolean(info.url);
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
</div>
@@ -1,10 +1,39 @@
import { ProjectData } from 'ontime-types';
import { ProjectData, RefetchKey } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { join } from 'node:path';
import { deleteFile } from '../../utils/fileManagement.js';
import { publicDir } from '../../setup/index.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
/**
* Gets a copy of the stored project data
* Gets the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
export function getProjectData(): Readonly<ProjectData> {
return getDataProvider().getProjectData();
}
/**
* Patches the current project data
* Handles deleting the local logo if the logo has been removed
*/
export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData);
// Delete the old logo if the logo has been removed
if (!updatedProjectData.logo && currentProjectData.logo) {
const filePath = join(publicDir.logoDir, currentProjectData.logo);
deleteFile(filePath).catch((_error) => {
/** we do not handle this error */
});
}
// Notify the websocket clients to refetch the project data
setImmediate(() => {
sendRefetch(RefetchKey.ProjectData);
});
return updatedProjectData;
}
@@ -8,7 +8,6 @@ import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
import * as projectDao from './projectData.dao.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
export const router = express.Router();
@@ -27,7 +26,7 @@ router.post('/', projectSanitiser, async (req: Request, res: Response<ProjectDat
custom: req.body?.custom,
});
const updatedData = await editCurrentProjectData(newData);
const updatedData = await projectDao.editCurrentProjectData(newData);
res.status(200).send(updatedData);
} catch (error) {
@@ -11,6 +11,7 @@ export const projectSanitiser = [
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
body('custom.*.url').optional().isString().trim().notEmpty(),
requestValidationFunction,
];
@@ -1,7 +1,6 @@
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { join } from 'path';
import { copyFile } from 'fs/promises';
import { logger } from '../../classes/Logger.js';
@@ -312,23 +311,3 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
const updatedData = await getDataProvider().getData();
return updatedData;
}
/**
* Patches the current project data
* Handles deleting the local logo if the logo has been removed
*/
export async function editCurrentProjectData(newData: Partial<ProjectData>) {
const currentProjectData = getDataProvider().getProjectData();
const updatedProjectData = await getDataProvider().setProjectData(newData);
// Delete the old logo if the logo has been removed
if (!updatedProjectData.logo && currentProjectData.logo) {
const filePath = join(publicDir.logoDir, currentProjectData.logo);
deleteFile(filePath).catch((_error) => {
/** we do not handle this error */
});
}
return updatedProjectData;
}
@@ -1,6 +1,7 @@
export enum RefetchKey {
All = 'all',
CustomFields = 'custom-fields',
ProjectData = 'project-data',
Report = 'report',
Rundown = 'rundown',
ViewSettings = 'view-settings',
@@ -4,5 +4,5 @@ export type ProjectData = {
url: string;
info: string;
logo: string | null;
custom: { title: string; value: string }[];
custom: { title: string; value: string; url: string }[];
};