mirror of
https://github.com/docmost/docmost.git
synced 2025-11-20 05:51:10 +10: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:
165
apps/client/src/components/common/avatar-uploader.tsx
Normal file
165
apps/client/src/components/common/avatar-uploader.tsx
Normal file
@ -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<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
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<HTMLInputElement>(null);
|
||||
|
||||
const handleFileInputChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
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 (
|
||||
<Box>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "relative", display: "inline-block" }}>
|
||||
<CustomAvatar
|
||||
component="button"
|
||||
size={size}
|
||||
avatarUrl={currentImageUrl}
|
||||
name={fallbackName}
|
||||
style={{
|
||||
cursor: disabled || isLoading ? "default" : "pointer",
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
}}
|
||||
radius={radius}
|
||||
variant={variant}
|
||||
type={type}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconUpload size={16} />}
|
||||
disabled={isLoading || disabled}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
{t("Upload image")}
|
||||
</Menu.Item>
|
||||
|
||||
{currentImageUrl && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{t("Remove image")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
|
||||
@ -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 (
|
||||
<Avatar
|
||||
|
||||
Reference in New Issue
Block a user