From 1280f96f378c71f81c18a753375205a38d1f5121 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:11:37 +0100 Subject: [PATCH] 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 --- .../public/locales/en-US/translation.json | 8 +- .../src/components/common/avatar-uploader.tsx | 165 ++++++++++ .../components/layouts/global/top-menu.tsx | 5 +- .../src/components/ui/custom-avatar.tsx | 7 +- .../services/attachment-service.ts | 64 ++++ .../features/attachments/services/index.ts | 9 + .../attachments/types/attachment.types.ts | 29 ++ .../editor/components/drawio/drawio-view.tsx | 2 +- .../components/excalidraw/excalidraw-view.tsx | 2 +- .../src/features/editor/styles/core.css | 7 +- .../features/page/services/page-service.ts | 3 +- .../space/components/settings-modal.tsx | 38 +-- .../space/components/sidebar/space-select.tsx | 17 +- .../components/sidebar/space-sidebar.tsx | 6 +- .../space/components/sidebar/switch-space.tsx | 34 ++- .../space/components/space-details.tsx | 103 +++++-- .../space/components/space-grid.module.css | 2 +- .../features/space/components/space-grid.tsx | 12 +- .../features/space/components/space-list.tsx | 8 +- .../spaces-page/all-spaces-list.tsx | 13 +- .../features/space/services/space-service.ts | 1 - .../src/features/space/types/space.types.ts | 4 +- .../user/components/account-avatar.tsx | 77 ++--- .../features/user/services/user-service.ts | 13 - .../settings/components/workspace-icon.tsx | 67 ++++ .../workspace/services/workspace-service.ts | 12 - apps/client/src/lib/config.ts | 8 +- apps/client/src/lib/types.ts | 17 -- .../settings/workspace/workspace-settings.tsx | 2 + apps/server/package.json | 3 +- apps/server/src/common/helpers/utils.ts | 4 +- .../core/attachment/attachment.constants.ts | 6 +- .../core/attachment/attachment.controller.ts | 59 +++- .../src/core/attachment/attachment.utils.ts | 62 +++- .../src/core/attachment/dto/attachment.dto.ts | 17 ++ .../core/attachment/dto/avatar-upload.dto.ts | 3 - .../src/core/attachment/dto/get-file.dto.ts | 7 - .../dto/page-attachment-upload.dto.ts | 0 .../core/attachment/dto/upload-file.dto.ts | 20 -- .../attachment/services/attachment.service.ts | 51 +++- pnpm-lock.yaml | 289 +++++++++++++++++- 41 files changed, 1043 insertions(+), 213 deletions(-) create mode 100644 apps/client/src/components/common/avatar-uploader.tsx create mode 100644 apps/client/src/features/attachments/services/attachment-service.ts create mode 100644 apps/client/src/features/attachments/services/index.ts create mode 100644 apps/client/src/features/attachments/types/attachment.types.ts create mode 100644 apps/client/src/features/workspace/components/settings/components/workspace-icon.tsx create mode 100644 apps/server/src/core/attachment/dto/attachment.dto.ts delete mode 100644 apps/server/src/core/attachment/dto/avatar-upload.dto.ts delete mode 100644 apps/server/src/core/attachment/dto/get-file.dto.ts delete mode 100644 apps/server/src/core/attachment/dto/page-attachment-upload.dto.ts delete mode 100644 apps/server/src/core/attachment/dto/upload-file.dto.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 1dd6ef58..6d9e548b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -527,5 +527,11 @@ "Delete SSO provider": "Delete SSO provider", "Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?", "Action": "Action", - "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration" + "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration", + "Icon": "Icon", + "Upload image": "Upload image", + "Remove image": "Remove image", + "Failed to remove image": "Failed to remove image", + "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", + "Image removed successfully": "Image removed successfully" } diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx new file mode 100644 index 00000000..0c83411c --- /dev/null +++ b/apps/client/src/components/common/avatar-uploader.tsx @@ -0,0 +1,165 @@ +import React, { useRef } from "react"; +import { Menu, Box, Loader } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { IconTrash, IconUpload } from "@tabler/icons-react"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { notifications } from "@mantine/notifications"; + +interface AvatarUploaderProps { + currentImageUrl?: string | null; + fallbackName?: string; + radius?: string | number; + size?: string | number; + variant?: string; + type: AvatarIconType; + onUpload: (file: File) => Promise; + onRemove: () => Promise; + isLoading?: boolean; + disabled?: boolean; +} + +export default function AvatarUploader({ + currentImageUrl, + fallbackName, + radius, + variant, + size, + type, + onUpload, + onRemove, + isLoading = false, + disabled = false, +}: AvatarUploaderProps) { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + + const handleFileInputChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file || disabled) { + return; + } + + // Validate file size (max 10MB) + const maxSizeInBytes = 10 * 1024 * 1024; + if (file.size > maxSizeInBytes) { + notifications.show({ + message: t("Image exceeds 10MB limit."), + color: "red", + }); + // Reset the input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + + try { + await onUpload(file); + } catch (error) { + console.error(error); + notifications.show({ + message: t("Failed to upload image"), + color: "red", + }); + } + + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleUploadClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } else { + console.error("File input ref is null!"); + } + }; + + const handleRemove = async () => { + if (disabled) return; + + try { + await onRemove(); + notifications.show({ + message: t("Image removed successfully"), + }); + } catch (error) { + console.error(error); + notifications.show({ + message: t("Failed to remove image"), + color: "red", + }); + } + }; + + return ( + + + + + + + + {isLoading && ( + + + + )} + + + + + } + disabled={isLoading || disabled} + onClick={handleUploadClick} + > + {t("Upload image")} + + + {currentImageUrl && ( + } + color="red" + onClick={handleRemove} + disabled={isLoading || disabled} + > + {t("Remove image")} + + )} + + + + ); +} diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx index d3a89ecc..84925080 100644 --- a/apps/client/src/components/layouts/global/top-menu.tsx +++ b/apps/client/src/components/layouts/global/top-menu.tsx @@ -1,8 +1,8 @@ import { Group, Menu, - UnstyledButton, Text, + UnstyledButton, useMantineColorScheme, } from "@mantine/core"; import { @@ -10,7 +10,6 @@ import { IconBrush, IconCheck, IconChevronDown, - IconChevronRight, IconDeviceDesktop, IconLogout, IconMoon, @@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts"; import useAuth from "@/features/auth/hooks/use-auth.ts"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { useTranslation } from "react-i18next"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; export default function TopMenu() { const { t } = useTranslation(); @@ -50,6 +50,7 @@ export default function TopMenu() { name={workspace?.name} variant="filled" size="sm" + type={AvatarIconType.WORKSPACE_ICON} /> {workspace?.name} diff --git a/apps/client/src/components/ui/custom-avatar.tsx b/apps/client/src/components/ui/custom-avatar.tsx index de4456e5..54730127 100644 --- a/apps/client/src/components/ui/custom-avatar.tsx +++ b/apps/client/src/components/ui/custom-avatar.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Avatar } from "@mantine/core"; import { getAvatarUrl } from "@/lib/config.ts"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; interface CustomAvatarProps { avatarUrl: string; @@ -11,13 +12,15 @@ interface CustomAvatarProps { variant?: string; style?: any; component?: any; + type?: AvatarIconType; + mt?: string | number; } export const CustomAvatar = React.forwardRef< HTMLInputElement, CustomAvatarProps ->(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => { - const avatarLink = getAvatarUrl(avatarUrl); +>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => { + const avatarLink = getAvatarUrl(avatarUrl, type); return ( { + const formData = new FormData(); + formData.append("type", type); + if (spaceId) { + formData.append("spaceId", spaceId); + } + formData.append("image", file); + + return await api.post("/attachments/upload-image", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); +} + +export async function uploadUserAvatar(file: File): Promise { + return uploadIcon(file, AvatarIconType.AVATAR); +} + +export async function uploadSpaceIcon( + file: File, + spaceId: string, +): Promise { + return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId); +} + +export async function uploadWorkspaceIcon(file: File): Promise { + return uploadIcon(file, AvatarIconType.WORKSPACE_ICON); +} + +async function removeIcon( + type: AvatarIconType, + spaceId?: string, +): Promise { + const payload: { spaceId?: string; type: string } = { type }; + + if (spaceId) { + payload.spaceId = spaceId; + } + + await api.post("/attachments/remove-icon", payload); +} + +export async function removeAvatar(): Promise { + await removeIcon(AvatarIconType.AVATAR); +} + +export async function removeSpaceIcon(spaceId: string): Promise { + await removeIcon(AvatarIconType.SPACE_ICON, spaceId); +} + +export async function removeWorkspaceIcon(): Promise { + await removeIcon(AvatarIconType.WORKSPACE_ICON); +} diff --git a/apps/client/src/features/attachments/services/index.ts b/apps/client/src/features/attachments/services/index.ts new file mode 100644 index 00000000..1732ba9f --- /dev/null +++ b/apps/client/src/features/attachments/services/index.ts @@ -0,0 +1,9 @@ +export { + uploadIcon, + uploadUserAvatar, + uploadSpaceIcon, + uploadWorkspaceIcon, + removeAvatar, + removeSpaceIcon, + removeWorkspaceIcon, +} from "./attachment-service.ts"; diff --git a/apps/client/src/features/attachments/types/attachment.types.ts b/apps/client/src/features/attachments/types/attachment.types.ts new file mode 100644 index 00000000..018d8c7c --- /dev/null +++ b/apps/client/src/features/attachments/types/attachment.types.ts @@ -0,0 +1,29 @@ +export interface IAttachment { + id: string; + fileName: string; + filePath: string; + fileSize: number; + fileExt: string; + mimeType: string; + type: string; + creatorId: string; + pageId: string | null; + spaceId: string | null; + workspaceId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export enum AvatarIconType { + AVATAR = "avatar", + SPACE_ICON = "space-icon", + WORKSPACE_ICON = "workspace-icon", +} + +export enum AttachmentType { + AVATAR = "avatar", + WORKSPACE_ICON = "workspace-icon", + SPACE_ICON = "space-icon", + FILE = "file", +} diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index bb901044..c09c628e 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -17,7 +17,7 @@ import { EventExit, EventSave, } from "react-drawio"; -import { IAttachment } from "@/lib/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 35d8de94..11b5e363 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks"; import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; -import { IAttachment } from "@/lib/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index f08f5aa9..479d000d 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -94,7 +94,12 @@ hr { border: none; - border-top: 1px solid #ced4da; + @mixin light { + border-top: 1px solid var(--mantine-color-gray-4); + } + @mixin dark { + border-top: 1px solid var(--mantine-color-dark-4); + } &:hover { cursor: pointer; diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index cf42ec6f..8d76438a 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -9,10 +9,11 @@ import { SidebarPagesParams, } from '@/features/page/types/page.types'; import { QueryParams } from "@/lib/types"; -import { IAttachment, IPagination } from "@/lib/types.ts"; +import { IPagination } from "@/lib/types.ts"; import { saveAs } from "file-saver"; import { InfiniteData } from "@tanstack/react-query"; import { IFileTask } from '@/features/file-task/types/file-task.types.ts'; +import { IAttachment } from '@/features/attachments/types/attachment.types.ts'; export async function createPage(data: Partial): Promise { const req = await api.post("/pages/create", data); diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 35f3a65f..1afce237 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -1,10 +1,10 @@ -import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core"; +import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core"; import SpaceMembersList from "@/features/space/components/space-members.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; -import React, {useMemo} from "react"; +import React from "react"; import SpaceDetails from "@/features/space/components/space-details.tsx"; -import {useSpaceQuery} from "@/features/space/queries/space-query.ts"; -import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { SpaceCaslAction, SpaceCaslSubject, @@ -39,16 +39,18 @@ export default function SpaceSettingsModal({ xOffset={0} mah={400} > - - + + - {space?.name} + + {space?.name} + - + -
+
@@ -60,13 +62,15 @@ export default function SpaceSettingsModal({ - + + + @@ -74,7 +78,7 @@ export default function SpaceSettingsModal({ {spaceAbility.can( SpaceCaslAction.Manage, SpaceCaslSubject.Member, - ) && } + ) && } void; @@ -16,7 +18,14 @@ interface SpaceSelectProps { const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( - +
{option.label} @@ -50,6 +59,7 @@ export function SpaceSelect({ return { label: space.name, value: space.slug, + icon: space.logo, }; }); @@ -76,7 +86,6 @@ export function SpaceSelect({ onChange={(slug) => onChange(spaces.items?.find((item) => item.slug === slug)) } - // duct tape onClick={(e) => e.stopPropagation()} nothingFoundMessage={t("No space found")} limit={50} diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index d650b178..ced237be 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -74,7 +74,11 @@ export function SpaceSidebar() { marginBottom: 3, }} > - +
diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index dc47b778..60349ecc 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -1,17 +1,25 @@ -import classes from './switch-space.module.css'; -import { useNavigate } from 'react-router-dom'; -import { SpaceSelect } from './space-select'; -import { getSpaceUrl } from '@/lib/config'; -import { Avatar, Button, Popover, Text } from '@mantine/core'; -import { IconChevronDown } from '@tabler/icons-react'; -import { useDisclosure } from '@mantine/hooks'; +import classes from "./switch-space.module.css"; +import { useNavigate } from "react-router-dom"; +import { SpaceSelect } from "./space-select"; +import { getSpaceUrl } from "@/lib/config"; +import { Button, Popover, Text } from "@mantine/core"; +import { IconChevronDown } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import React from "react"; interface SwitchSpaceProps { spaceName: string; spaceSlug: string; + spaceIcon?: string; } -export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) { +export function SwitchSpace({ + spaceName, + spaceSlug, + spaceIcon, +}: SwitchSpaceProps) { const navigate = useNavigate(); const [opened, { close, open, toggle }] = useDisclosure(false); @@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) { color="gray" onClick={open} > - {spaceName} @@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) { handleSelect(space.slug)} + onChange={(space) => handleSelect(space.slug)} width={300} opened={true} /> diff --git a/apps/client/src/features/space/components/space-details.tsx b/apps/client/src/features/space/components/space-details.tsx index fca5aaec..3c3eb178 100644 --- a/apps/client/src/features/space/components/space-details.tsx +++ b/apps/client/src/features/space/components/space-details.tsx @@ -1,11 +1,23 @@ -import React from 'react'; -import { useSpaceQuery } from '@/features/space/queries/space-query.ts'; -import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx'; -import { Button, Divider, Group, Text } from '@mantine/core'; -import DeleteSpaceModal from './delete-space-modal'; +import React, { useState } from "react"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx"; +import { Button, Divider, Text } from "@mantine/core"; +import DeleteSpaceModal from "./delete-space-modal"; import { useDisclosure } from "@mantine/hooks"; import ExportModal from "@/components/common/export-modal.tsx"; +import AvatarUploader from "@/components/common/avatar-uploader.tsx"; +import { + uploadSpaceIcon, + removeSpaceIcon, +} from "@/features/attachments/services/attachment-service.ts"; import { useTranslation } from "react-i18next"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { queryClient } from "@/main.tsx"; +import { + ResponsiveSettingsContent, + ResponsiveSettingsControl, + ResponsiveSettingsRow, +} from "@/components/ui/responsive-settings-row.tsx"; interface SpaceDetailsProps { spaceId: string; @@ -13,9 +25,40 @@ interface SpaceDetailsProps { } export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { const { t } = useTranslation(); - const { data: space, isLoading } = useSpaceQuery(spaceId); + const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); + const [isIconUploading, setIsIconUploading] = useState(false); + + const handleIconUpload = async (file: File) => { + setIsIconUploading(true); + try { + await uploadSpaceIcon(file, spaceId); + await refetch(); + await queryClient.invalidateQueries({ + predicate: (item) => ["spaces"].includes(item.queryKey[0] as string), + }); + } catch (err) { + // skip + } finally { + setIsIconUploading(false); + } + }; + + const handleIconRemove = async () => { + setIsIconUploading(true); + try { + await removeSpaceIcon(spaceId); + await refetch(); + await queryClient.invalidateQueries({ + predicate: (item) => ["spaces"].includes(item.queryKey[0] as string), + }); + } catch (err) { + // skip + } finally { + setIsIconUploading(false); + } + }; return ( <> @@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { {t("Details")} + +
+ + {t("Icon")} + + +
+ {!readOnly && ( <> - - -
+ + {t("Export space")} {t("Export all pages and attachments in this space.")} -
- - -
+ + + + + - -
+ + {t("Delete space")} {t("Delete this space with all its pages and data.")} -
- - -
+ + + + + - {cards} - + {data?.items && data.items.length > 9 && (