mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 16:33:51 +00:00
feat: implement custom project data image
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user