- Add the two new strings to en-US locale ('Go to login page', 'Move to
space') so they aren't missing from the base locale (review note 1).
- Avatar upload: accept any image/* MIME instead of a hardcoded png/jpeg/jpg
list, so webp/gif/etc. are no longer wrongly rejected client-side while
genuine non-images still surface the error (review note 2).
- Reindex polling: align the deadline-clearing effect with the refetchInterval
stop condition (indexed >= total, empty workspace included) so the deadline
clears promptly instead of waiting out the cap (review note 3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
5.6 KiB
TypeScript
203 lines
5.6 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 type. The `accept` attribute only filters the dialog;
|
|
// a user can still select a non-image file, which previously failed
|
|
// silently. Surface a visible error instead (issue #133). Accept any
|
|
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
|
|
// what the server accepts; only genuinely non-image files are rejected.
|
|
if (!file.type.startsWith("image/")) {
|
|
notifications.show({
|
|
message: t("Unsupported image type"),
|
|
color: "red",
|
|
});
|
|
// Reset the input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
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);
|
|
// Notify on success so the upload gives visible feedback (issue #128)
|
|
notifications.show({ message: t("Image updated") });
|
|
} 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/*"
|
|
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>
|
|
);
|
|
}
|