mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user