Upload corporate logo (#1314)

* feat: upload logo to project data

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Ary
2024-11-17 14:55:20 -07:00
committed by GitHub
parent 8dd448db44
commit a6e1cbbbce
26 changed files with 289 additions and 13 deletions
+1
View File
@@ -26,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+16 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData } from 'ontime-types';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
@@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
};
+17 -1
View File
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (10MB) exceeded');
throw new Error('File size limit (1MB) exceeded');
}
}
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
/**
* Collection of rules for pre-validating a project file
* @param file
*/
export function validateLogo(file: File) {
// Check if file is empty
if (file.size === 0) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
}
}
@@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>;
}
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return (
<div className={style.card} {...props}>
<div className={cx([style.card, className])} {...props}>
{children}
</div>
);
@@ -1,11 +1,15 @@
import { useEffect } from 'react';
import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { type ProjectData } from 'ontime-types';
import { postProjectData } from '../../../../common/api/project';
import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -17,8 +21,10 @@ export default function ProjectData() {
handleSubmit,
register,
reset,
formState: { isSubmitting, isValid, isDirty },
formState: { isSubmitting, isValid, isDirty, errors },
setError,
watch,
setValue,
} = useForm({
defaultValues: data,
values: data,
@@ -34,6 +40,40 @@ export default function ProjectData() {
}
}, [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 = () => {
setValue('projectLogo', null, {
shouldDirty: true,
});
};
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
@@ -86,6 +126,53 @@ export default function ProjectData() {
{...register('title')}
/>
</label>
<Panel.Section style={{ marginTop: 0 }}>
<label>
Project logo
<Input
variant='ontime-filled'
size='sm'
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
size='sm'
variant='ontime-filled'
isDisabled={isSubmitting || !watch('projectLogo')}
leftIcon={<IoTrash />}
onClick={handleDeleteLogo}
type='button'
>
Delete
</Button>
</>
) : (
<Button
variant='ontime-filled'
size='sm'
isDisabled={isSubmitting}
leftIcon={<IoDownloadOutline />}
onClick={handleClickUpload}
type='button'
>
Upload logo
</Button>
)}
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
</Panel.Card>
</label>
</Panel.Section>
<label>
Project description
<Input
@@ -61,3 +61,26 @@
gap: 1rem;
}
}
.flex {
display: flex;
gap: 1rem;
height: 100px;
}
.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;
}
}
@@ -194,4 +194,12 @@
text-align: center;
font-weight: 600;
}
.logo {
position: absolute;
top: 2vw;
left: 2vw;
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
}
@@ -5,6 +5,7 @@ import {
MessageState,
OntimeEvent,
Playback,
ProjectData,
Settings,
SimpleTimerState,
TimerPhase,
@@ -12,6 +13,7 @@ import {
ViewSettings,
} from 'ontime-types';
import { projectLogoPath } from '../../../common/api/constants';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
@@ -50,6 +52,7 @@ interface TimerProps {
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
settings: Settings | undefined;
@@ -58,7 +61,8 @@ interface TimerProps {
}
export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
props;
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
@@ -168,6 +172,8 @@ export default function Timer(props: TimerProps) {
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
{general?.projectLogo && <img src={`${projectLogoPath}/${general.projectLogo}`} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
+37 -2
View File
@@ -1,9 +1,19 @@
import { DatabaseModel, ErrorResponse, MessageResponse, ProjectFileListResponse } from 'ontime-types';
import {
DatabaseModel,
ErrorResponse,
MessageResponse,
ProjectFileListResponse,
ProjectLogoResponse,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { doesProjectExist, handleUploaded } from '../../services/project-service/projectServiceUtils.js';
import {
doesProjectExist,
handleImageUpload,
handleUploaded,
} from '../../services/project-service/projectServiceUtils.js';
import * as projectService from '../../services/project-service/ProjectService.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
@@ -39,6 +49,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null,
},
});
@@ -124,6 +135,30 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
}
}
/**
* Uploads an image file to be used as a project logo.
* The image file is saved in the logo directory.
*/
export async function postProjectLogo(req: Request, res: Response<ProjectLogoResponse | ErrorResponse>) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const { filename, path } = req.file;
const logoFilename = await handleImageUpload(path, filename);
res.status(201).send({
logoFilename,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/**
* Retrieves and lists all project files from the uploads directory.
*/
@@ -12,8 +12,22 @@ const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFil
}
};
const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes('image')) {
cb(null, true);
} else {
cb(null, false);
}
}
// Build multer uploader for a single file
export const uploadProjectFile = multer({
storage,
fileFilter: filterProjectFile,
}).single('project');
// Build multer uploader for a single image file
export const uploadImageFile = multer({
storage,
fileFilter: filterImageFile,
}).single('image');
@@ -13,6 +13,7 @@ export const validateNewProject = [
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
@@ -2,12 +2,14 @@ import { ErrorResponse, ProjectData } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { join } from 'path';
import { removeUndefined } from '../../utils/parserUtils.js';
import { deleteFile, removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { publicDir } from '../../setup/index.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(getDataProvider().getProjectData());
}
@@ -17,6 +19,8 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
}
try {
const currentProjectData = getDataProvider().getProjectData();
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
@@ -25,8 +29,20 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo,
});
const newData = await getDataProvider().setProjectData(newEvent);
// Delete the old logo if the new logo is empty
if (!newData.projectLogo && currentProjectData.projectLogo) {
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
deleteFile(filePath).catch((_error) => {
/** we do not handle this error */
});
}
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -2,8 +2,11 @@ import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
router.post('/upload', uploadImageFile, postProjectLogo);
@@ -9,6 +9,7 @@ export const projectSanitiser = [
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -11,6 +11,7 @@ describe('safeMerge', () => {
backstageUrl: 'existing backstageUrl',
publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
},
settings: {
app: 'ontime',
@@ -77,6 +78,7 @@ describe('safeMerge', () => {
publicInfo: 'new public info',
backstageUrl: 'existing backstageUrl',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
});
});
+1
View File
@@ -10,6 +10,7 @@ export const dbModel: DatabaseModel = {
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
},
settings: {
app: 'ontime',
+1
View File
@@ -357,6 +357,7 @@ export const demoDb: DatabaseModel = {
publicInfo: 'Rehearsal Schedule - Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
projectLogo: null,
},
settings: {
app: 'ontime',
@@ -5,7 +5,7 @@ import { copyFile, readFile, rename, stat } from 'fs/promises';
import { extname, join } from 'path';
import { publicDir } from '../../setup/index.js';
import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
/**
* Handles the upload of a new project file
@@ -17,6 +17,14 @@ export async function handleUploaded(filePath: string, name: string) {
await rename(filePath, newFilePath);
}
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
ensureDirectory(publicDir.logoDir);
const newFilePath = join(publicDir.logoDir, name);
await rename(filePath, newFilePath);
return name;
}
/**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
+1
View File
@@ -21,4 +21,5 @@ export const config = {
filename: 'override.css',
},
uploads: 'uploads',
logo: 'logo',
};
+1
View File
@@ -129,6 +129,7 @@ export const publicDir = {
userDir: join(resolvePublicDirectory, config.user),
/** path to external styles override */
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
logoDir: join(resolvePublicDirectory, config.user, config.logo),
} as const;
/**
@@ -60,6 +60,34 @@ describe('parseProject()', () => {
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('test migration with adding the logo field v3.8.0', () => {
const errorEmitter = vi.fn();
const result = parseProject(
{
//@ts-expect-error -- checking migration when the logo field is added
project: {
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
},
},
errorEmitter,
);
expect(result).toStrictEqual({
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
projectLogo: null,
});
expect(errorEmitter).not.toHaveBeenCalled();
});
});
describe('parseSettings()', () => {
+1
View File
@@ -122,6 +122,7 @@ export function parseProject(data: Partial<DatabaseModel>, emitError?: ErrorEmit
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
};
}
@@ -42,6 +42,10 @@ export type MessageResponse = {
message: string;
};
export type ProjectLogoResponse = {
logoFilename: string;
};
export type ErrorResponse = MessageResponse;
export type AuthenticationStatus = 'authenticated' | 'not_authenticated' | 'pending';
@@ -5,4 +5,5 @@ export type ProjectData = {
publicInfo: string;
backstageUrl: string;
backstageInfo: string;
projectLogo: string | null;
};
+1
View File
@@ -52,6 +52,7 @@ export type {
MessageResponse,
RundownPaginated,
SessionStats,
ProjectLogoResponse,
} from './api/ontime-controller/BackendResponse.type.js';
export type { QuickStartData } from './api/db/db.type.js';
export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js';