mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 12:23:51 +00:00
Upload corporate logo (#1314)
* feat: upload logo to project data --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user