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
@@ -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}
>
<Modal.Overlay/>
<Modal.Content style={{overflow: "hidden"}}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title>
<Text fw={500} lineClamp={1}>{space?.name}</Text>
<Text fw={500} lineClamp={1}>
{space?.name}
</Text>
</Modal.Title>
<Modal.CloseButton/>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<div style={{height: rem(600)}}>
<div style={{ height: rem(600) }}>
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
@@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
</Tabs.List>
<Tabs.Panel value="general">
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
<ScrollArea h={550} scrollbarSize={4} pr={8}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="members">
@@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id}/>}
) && <AddSpaceMembersModal spaceId={space?.id} />}
</Group>
<SpaceMembersList
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface SpaceSelectProps {
onChange: (value: ISpace) => void;
@@ -16,7 +18,14 @@ interface SpaceSelectProps {
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm" wrap="nowrap">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<CustomAvatar
name={option.label}
avatarUrl={option?.["icon"]}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<div>
<Text size="sm" lineClamp={1}>
{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}
@@ -74,7 +74,11 @@ export function SpaceSidebar() {
marginBottom: 3,
}}
>
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
<SwitchSpace
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</div>
<div className={classes.section}>
@@ -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}
>
<Avatar
size={20}
<CustomAvatar
name={spaceName}
avatarUrl={spaceIcon}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={spaceName}
size={20}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName}
@@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={space => handleSelect(space.slug)}
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
/>
@@ -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) {
<Text my="md" fw={600}>
{t("Details")}
</Text>
<div style={{ marginBottom: "20px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={space.logo}
fallbackName={space.name}
size={"60px"}
variant="filled"
type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isIconUploading}
disabled={readOnly}
/>
</div>
<EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Export space")}</Text>
<Text size="sm" c="dimmed">
{t("Export all pages and attachments in this space.")}
</Text>
</div>
<Button onClick={openExportModal}>
{t("Export")}
</Button>
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Button onClick={openExportModal}>{t("Export")}</Button>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Delete space")}</Text>
<Text size="sm" c="dimmed">
{t("Delete this space with all its pages and data.")}
</Text>
</div>
<DeleteSpaceModal space={space} />
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<DeleteSpaceModal space={space} />
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<ExportModal
type="space"
@@ -7,7 +7,7 @@
}
.cardSection {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.title {
@@ -1,5 +1,5 @@
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React, { useEffect } from 'react';
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React from "react";
import {
prefetchSpace,
useGetSpacesQuery,
@@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceGrid() {
const { t } = useTranslation();
@@ -27,8 +29,10 @@ export default function SpaceGrid() {
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size="md"
@@ -54,7 +58,7 @@ export default function SpaceGrid() {
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
{data?.items && data.items.length > 9 && (
<Group justify="flex-end" mt="lg">
<Button
@@ -1,4 +1,4 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
@@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceList() {
const { t } = useTranslation();
@@ -39,8 +41,10 @@ export default function SpaceList() {
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Avatar
<CustomAvatar
color="initials"
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
variant="filled"
name={space.name}
/>
@@ -6,13 +6,12 @@ import {
Box,
Space,
Menu,
Avatar,
Anchor,
} from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { getSpaceUrl } from "@/lib/config";
@@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
import NoTableResults from "@/components/common/no-table-results";
import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface AllSpacesListProps {
spaces: any[];
@@ -87,11 +88,13 @@ export default function AllSpacesList({
className={classes.spaceLink}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={space.name}
size={40}
size="md"
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>