"use client"; import { Avatar, AvatarFallback, AvatarImage } from "../shadcnui"; import { S3Interface } from "../features/s3/data/s3.interface"; import { S3Service } from "../features/s3/data/s3.service"; import { ModuleWithPermissions } from "../permissions"; import { errorToast } from "./errors/errorToast"; import { PencilIcon, Trash2Icon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useRef, useState } from "react"; import { cn } from "../utils/cn"; type EditableAvatarProps = { entityId: string; module: ModuleWithPermissions; image?: string; fallback: string; alt: string; patchImage: (imageKey: string) => Promise; className?: string; fallbackClassName?: string; /** * Company id used to namespace the S3 upload key. Supplied by the caller from * whichever current-user context the host app instantiates, so this leaf * component does not depend on a specific provider being mounted. */ companyId: string; }; export function EditableAvatar({ entityId, module, image, fallback, alt, patchImage, className, fallbackClassName, companyId, }: EditableAvatarProps) { const t = useTranslations(); const fileInputRef = useRef(null); // Optimistic state: null means "use the prop", string means "override" const [optimisticImage, setOptimisticImage] = useState(null); const [isUploading, setIsUploading] = useState(false); const displayImage = optimisticImage ?? image; const generateS3Key = useCallback( (file: File) => { const ext = file.type.split("/").pop() ?? ""; const ts = new Date().toISOString().replace(/[-:T]/g, "").split(".")[0]; return `companies/${companyId}/${module.name}/${entityId}/${entityId}.${ts}.${ext}`; }, [companyId, module.name, entityId], ); const handleFile = useCallback( async (file: File) => { if (isUploading) return; if (!companyId) return; const previousImage = image; const previewUrl = URL.createObjectURL(file); setOptimisticImage(previewUrl); setIsUploading(true); try { const s3Key = generateS3Key(file); const s3: S3Interface = await S3Service.getPreSignedUrl({ key: s3Key, contentType: file.type, isPublic: true, }); const uploadResponse = await fetch(s3.url, { method: "PUT", headers: s3.headers, body: file, }); if (!uploadResponse.ok) { throw new Error(`S3 upload failed: ${uploadResponse.status}`); } await patchImage(s3Key); setOptimisticImage(null); } catch (error) { setOptimisticImage(previousImage ?? null); errorToast({ title: t("generic.errors.update"), error }); } finally { URL.revokeObjectURL(previewUrl); setIsUploading(false); } }, [companyId, generateS3Key, image, isUploading, patchImage, t], ); const handleRemove = useCallback(async () => { if (isUploading) return; const previousImage = image; setOptimisticImage(""); setIsUploading(true); try { await patchImage(""); setOptimisticImage(null); } catch (error) { setOptimisticImage(previousImage ?? null); errorToast({ title: t("generic.errors.update"), error }); } finally { setIsUploading(false); } }, [image, isUploading, patchImage, t]); const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFile(file); e.target.value = ""; }, [handleFile], ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith("image/")) { handleFile(file); } }, [handleFile], ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); return (
{displayImage ? : null} {fallback} {/* Hover overlay */}
{displayImage && ( )}
); }