mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-01 21:47:59 +00:00
Compare commits
4 Commits
master
...
manage-logo
| Author | SHA1 | Date | |
|---|---|---|---|
| 15feb46131 | |||
| 55cf0efa64 | |||
| 19fc887d6f | |||
| e3f18b7ecc |
@@ -1,5 +1,5 @@
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -17,9 +17,16 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './SettingsPanel.module.scss';
|
||||
|
||||
type PendingLogo = {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
uploadedFilename?: string;
|
||||
};
|
||||
|
||||
export default function ProjectData() {
|
||||
const { data, status } = useProjectData();
|
||||
const { updateProjectData } = useUpdateProjectData();
|
||||
const [pendingLogo, setPendingLogo] = useState<PendingLogo | null>(null);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -45,13 +52,22 @@ export default function ProjectData() {
|
||||
});
|
||||
|
||||
// reset form values if data changes
|
||||
// Release the browser-managed preview URL when the selection changes or this form unmounts.
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingLogo) {
|
||||
URL.revokeObjectURL(pendingLogo.previewUrl);
|
||||
}
|
||||
};
|
||||
}, [pendingLogo]);
|
||||
|
||||
const handleUploadProjectLogo = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
clearErrors('logo');
|
||||
|
||||
@@ -61,14 +77,16 @@ export default function ProjectData() {
|
||||
|
||||
try {
|
||||
validateLogo(file);
|
||||
const response = await uploadProjectLogo(file);
|
||||
|
||||
setValue('logo', response.data.logoFilename, {
|
||||
shouldDirty: true,
|
||||
setPendingLogo({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('logo', { message });
|
||||
} finally {
|
||||
// Allow selecting the same local file again after an upload attempt.
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,6 +101,7 @@ export default function ProjectData() {
|
||||
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingLogo(null);
|
||||
setValue('logo', null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
@@ -92,10 +111,29 @@ export default function ProjectData() {
|
||||
append({ title: '', value: '', url: '' });
|
||||
};
|
||||
|
||||
const uploadPendingLogo = async () => {
|
||||
if (!pendingLogo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pendingLogo.uploadedFilename) {
|
||||
return pendingLogo.uploadedFilename;
|
||||
}
|
||||
|
||||
const response = await uploadProjectLogo(pendingLogo.file);
|
||||
const uploadedFilename = response.data.logoFilename;
|
||||
setPendingLogo((currentPendingLogo) =>
|
||||
currentPendingLogo?.file === pendingLogo.file ? { ...currentPendingLogo, uploadedFilename } : currentPendingLogo,
|
||||
);
|
||||
return uploadedFilename;
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
clearErrors();
|
||||
await updateProjectData(formData);
|
||||
const logo = (await uploadPendingLogo()) ?? formData.logo;
|
||||
await updateProjectData({ ...formData, logo });
|
||||
setPendingLogo(null);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
@@ -104,10 +142,12 @@ export default function ProjectData() {
|
||||
|
||||
// reset data to the initial state
|
||||
const onReset = () => {
|
||||
setPendingLogo(null);
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const logo = watch('logo');
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
|
||||
@@ -118,7 +158,12 @@ export default function ProjectData() {
|
||||
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={(!isDirty && !pendingLogo) || !isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
@@ -150,17 +195,24 @@ export default function ProjectData() {
|
||||
onChange={handleUploadProjectLogo}
|
||||
/>
|
||||
<Panel.Card className={style.uploadLogoCard}>
|
||||
{watch('logo') ? (
|
||||
{logo || pendingLogo ? (
|
||||
<>
|
||||
<img src={`${projectLogoPath}/${watch('logo')}`} />
|
||||
<Button
|
||||
variant='subtle-destructive'
|
||||
disabled={isSubmitting || !watch('logo')}
|
||||
onClick={handleDeleteLogo}
|
||||
>
|
||||
<IoTrash />
|
||||
Delete
|
||||
</Button>
|
||||
<img src={pendingLogo ? pendingLogo.previewUrl : `${projectLogoPath}/${logo}`} />
|
||||
<div className={style.logoActions}>
|
||||
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
|
||||
<IoDownloadOutline />
|
||||
Replace logo
|
||||
</Button>
|
||||
<Button
|
||||
variant='subtle-destructive'
|
||||
disabled={isSubmitting || (!logo && !pendingLogo)}
|
||||
onClick={handleDeleteLogo}
|
||||
type='button'
|
||||
>
|
||||
<IoTrash />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.logoActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.customDataItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -16,19 +16,13 @@ export function getProjectData(): Readonly<ProjectData> {
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
});
|
||||
if (currentProjectData.logo && currentProjectData.logo !== updatedProjectData.logo) {
|
||||
deleteFile(join(publicDir.logoDir, currentProjectData.logo));
|
||||
}
|
||||
|
||||
// Notify the websocket clients to refetch the project data
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile, stat } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import { dockerSafeRename, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
||||
@@ -20,15 +21,16 @@ export async function handleProjectUploaded(filePath: string, name: string) {
|
||||
|
||||
/**
|
||||
* Handles the upload of a logo image
|
||||
* @throws if the file already exits
|
||||
* Each upload gets a unique filename so logos cannot conflict across projects.
|
||||
* @param filePath
|
||||
* @param name
|
||||
* @returns
|
||||
* @returns the generated filename
|
||||
*/
|
||||
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
|
||||
const newFilePath = join(publicDir.logoDir, name);
|
||||
const filename = `${generateId()}${extname(name)}`;
|
||||
const newFilePath = join(publicDir.logoDir, filename);
|
||||
await dockerSafeRename(filePath, newFilePath);
|
||||
return name;
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"test:pipeline": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge-ts": "^7.0.3",
|
||||
"nanoid": "^5.0.7"
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"nanoid": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ontime-types": "workspace:*",
|
||||
|
||||
Generated
+7
-7
@@ -328,11 +328,11 @@ importers:
|
||||
packages/utils:
|
||||
dependencies:
|
||||
deepmerge-ts:
|
||||
specifier: ^7.0.3
|
||||
specifier: ^7.1.5
|
||||
version: 7.1.5
|
||||
nanoid:
|
||||
specifier: ^5.0.7
|
||||
version: 5.1.5
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
devDependencies:
|
||||
ontime-types:
|
||||
specifier: workspace:*
|
||||
@@ -4279,9 +4279,9 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.5:
|
||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||
engines: {node: ^18 || >=20}
|
||||
nanoid@6.0.0:
|
||||
resolution: {integrity: sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==}
|
||||
engines: {node: ^22 || ^24 || >=26}
|
||||
hasBin: true
|
||||
|
||||
negotiator@1.0.0:
|
||||
@@ -9209,7 +9209,7 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
nanoid@6.0.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user