feat: implement space and workspace icons (#1558)

* feat: implement space and workspace icons
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Add Sharp package for server-side image resizing and optimization
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Support removing icons

* add workspace logo support
- add upload loader
- add white background to transparent image
- other fixes and enhancements

* dark mode

* fixes

* cleanup
This commit is contained in:
Philip Okugbe
2025-09-15 21:11:37 +01:00
committed by GitHub
parent 61d1cf88a7
commit 1280f96f37
41 changed files with 1043 additions and 213 deletions
@@ -7,6 +7,7 @@ import {
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
compressAndResizeIcon,
getAttachmentFolderPath,
PreparedFile,
prepareFile,
@@ -16,7 +17,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { AttachmentType, validImageExtensions } from '../attachment.constants';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Attachment } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -132,8 +133,8 @@ export class AttachmentService {
filePromise: Promise<MultipartFile>,
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceLogo
| AttachmentType.SpaceLogo,
| AttachmentType.WorkspaceIcon
| AttachmentType.SpaceIcon,
userId: string,
workspaceId: string,
spaceId?: string,
@@ -141,6 +142,9 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
@@ -174,7 +178,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.WorkspaceLogo) {
} else if (type === AttachmentType.WorkspaceIcon) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
@@ -186,7 +190,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.SpaceLogo && spaceId) {
} else if (type === AttachmentType.SpaceIcon && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx,
});
@@ -205,7 +209,6 @@ export class AttachmentService {
});
} catch (err) {
// delete uploaded file on db update failure
this.logger.error('Image upload error:', err);
await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image');
}
@@ -389,4 +392,40 @@ export class AttachmentService {
}
}
async removeUserAvatar(user: User) {
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
await this.deleteRedundantFile(filePath);
}
await this.userRepo.updateUser(
{ avatarUrl: null },
user.id,
user.workspaceId,
);
}
async removeSpaceIcon(spaceId: string, workspaceId: string) {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
}
async removeWorkspaceIcon(workspace: Workspace) {
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
}
}