diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index a75d75385..789e1ce87 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -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`; diff --git a/apps/client/src/common/api/project.ts b/apps/client/src/common/api/project.ts index 11b14d267..50e7689d6 100644 --- a/apps/client/src/common/api/project.ts +++ b/apps/client/src/common/api/project.ts @@ -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 { export async function postProjectData(data: ProjectData): Promise> { return axios.post(projectPath, data); } + +/** + * HTTP request to upload a project logo + */ +export async function uploadProjectLogo(file: File): Promise> { + const formData = new FormData(); + formData.append('image', file); + const response = await axios.post(`${projectPath}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response; +} diff --git a/apps/client/src/common/models/ProjectData.ts b/apps/client/src/common/models/ProjectData.ts index 142748e82..4d7a0e57c 100644 --- a/apps/client/src/common/models/ProjectData.ts +++ b/apps/client/src/common/models/ProjectData.ts @@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = { publicInfo: '', backstageUrl: '', backstageInfo: '', + projectLogo: null, }; diff --git a/apps/client/src/common/utils/uploadUtils.ts b/apps/client/src/common/utils/uploadUtils.ts index 83098e876..95a67365a 100644 --- a/apps/client/src/common/utils/uploadUtils.ts +++ b/apps/client/src/common/utils/uploadUtils.ts @@ -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'); + } +} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index 67e4a4422..4020d73d6 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) { return

{children}

; } -export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) { +export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) { return ( -
+
{children}
); diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectData.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectData.tsx index 2bcede449..d3a0e2baf 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectData.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectData.tsx @@ -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) => { + 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(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')} /> + + +