mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 09:14:07 +08:00
184 lines
4.8 KiB
TypeScript
184 lines
4.8 KiB
TypeScript
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 actionLabel = {
|
||
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||
}[type];
|
||
|
||
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
|
||
// visible text. When no image is set, the avatar renders the name's
|
||
// initials, so prepend the name to the action label.
|
||
const ariaLabel =
|
||
!currentImageUrl && fallbackName
|
||
? `${fallbackName} – ${actionLabel}`
|
||
: actionLabel;
|
||
|
||
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"
|
||
aria-label={ariaLabel}
|
||
tabIndex={-1}
|
||
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}
|
||
aria-label={ariaLabel}
|
||
aria-haspopup="menu"
|
||
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: 200,
|
||
}}
|
||
>
|
||
<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>
|
||
);
|
||
}
|