feat(payers): upload de avatar via arquivo com redimensionamento client-side

- círculo de upload no final da grade de avatares abre seletor de arquivo
- imagem redimensionada para 200×200px via Canvas e salva como base64
- suporte a data URLs em next/image com prop unoptimized
- object-cover adicionado ao componente base Avatar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-11 22:43:28 +00:00
parent fa41c78a39
commit 9b8ac9f71f
7 changed files with 138 additions and 18 deletions

View File

@@ -10,7 +10,11 @@ const nextConfig: NextConfig = {
reactCompiler: true, reactCompiler: true,
images: { images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")], remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),
{ protocol: "https", hostname: "**" },
{ protocol: "http", hostname: "**" },
],
}, },
devIndicators: { devIndicators: {
position: "bottom-right", position: "bottom-right",

View File

@@ -52,6 +52,7 @@ export function PayerHeaderCard({
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(payer.avatarUrl); const avatarSrc = getAvatarSrc(payer.avatarUrl);
const isDataUrl = avatarSrc.startsWith("data:");
const createdAtLabel = formatDate(payer.createdAt); const createdAtLabel = formatDate(payer.createdAt);
const isAdmin = payer.role === PAYER_ROLE_ADMIN; const isAdmin = payer.role === PAYER_ROLE_ADMIN;
@@ -109,6 +110,7 @@ export function PayerHeaderCard({
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden"> <div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
<Image <Image
src={avatarSrc} src={avatarSrc}
unoptimized={isDataUrl}
alt={`Avatar de ${payer.name}`} alt={`Avatar de ${payer.name}`}
width={64} width={64}
height={64} height={64}

View File

@@ -24,6 +24,7 @@ interface PayerCardProps {
export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) { export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
const avatarSrc = getAvatarSrc(payer.avatarUrl); const avatarSrc = getAvatarSrc(payer.avatarUrl);
const isAdmin = payer.role === PAYER_ROLE_ADMIN; const isAdmin = payer.role === PAYER_ROLE_ADMIN;
const isDataUrl = avatarSrc.startsWith("data:");
const isReadOnly = !payer.canEdit; const isReadOnly = !payer.canEdit;
return ( return (
@@ -33,6 +34,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
<div className="relative mb-3 flex size-16 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg"> <div className="relative mb-3 flex size-16 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg">
<Image <Image
src={avatarSrc} src={avatarSrc}
unoptimized={isDataUrl}
alt={`Avatar de ${payer.name}`} alt={`Avatar de ${payer.name}`}
width={80} width={80}
height={80} height={80}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { RiImageAddLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createPayerAction, createPayerAction,
@@ -37,6 +38,45 @@ import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { StatusSelectContent } from "./payer-select-items"; import { StatusSelectContent } from "./payer-select-items";
import type { Payer, PayerFormValues } from "./types"; import type { Payer, PayerFormValues } from "./types";
const AVATAR_MAX_SIZE = 200;
function resizeImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new window.Image();
img.onload = () => {
let { width, height } = img;
if (width > height) {
if (width > AVATAR_MAX_SIZE) {
height = Math.round((height * AVATAR_MAX_SIZE) / width);
width = AVATAR_MAX_SIZE;
}
} else {
if (height > AVATAR_MAX_SIZE) {
width = Math.round((width * AVATAR_MAX_SIZE) / height);
height = AVATAR_MAX_SIZE;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas não disponível"));
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", 0.85));
};
img.onerror = () => reject(new Error("Falha ao carregar imagem"));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error("Falha ao ler arquivo"));
reader.readAsDataURL(file);
});
}
type PayerCreatePayload = Parameters<typeof createPayerAction>[0]; type PayerCreatePayload = Parameters<typeof createPayerAction>[0];
interface PayerDialogProps { interface PayerDialogProps {
@@ -77,8 +117,10 @@ export function PayerDialog({
}: PayerDialogProps) { }: PayerDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [uploadedAvatar, setUploadedAvatar] = useState<string | null>(null);
const [isProcessingImage, setIsProcessingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
open, open,
false, false,
@@ -90,26 +132,47 @@ export function PayerDialog({
[payer, avatarOptions], [payer, avatarOptions],
); );
// Use form state hook for form management
const { formState, resetForm, updateField } = const { formState, resetForm, updateField } =
useFormState<PayerFormValues>(initialState); useFormState<PayerFormValues>(initialState);
// Avatares da biblioteca excluem data URLs (que ficam no círculo de upload)
const availableAvatars = useMemo(() => { const availableAvatars = useMemo(() => {
const set = new Set([ const set = new Set([...avatarOptions, DEFAULT_PAYER_AVATAR]);
...avatarOptions, if (initialState.avatarUrl && !initialState.avatarUrl.startsWith("data:")) {
initialState.avatarUrl, set.add(initialState.avatarUrl);
DEFAULT_PAYER_AVATAR, }
]);
return Array.from(set).sort((a, b) => return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }), a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
); );
}, [avatarOptions, initialState.avatarUrl]); }, [avatarOptions, initialState.avatarUrl]);
// Reset form when dialog opens const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessingImage(true);
try {
const base64 = await resizeImageToBase64(file);
setUploadedAvatar(base64);
updateField("avatarUrl", base64);
} catch {
toast.error("Não foi possível processar a imagem.");
} finally {
setIsProcessingImage(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
resetForm(initialState); resetForm(initialState);
setErrorMessage(null); setErrorMessage(null);
setIsProcessingImage(false);
// Se o avatar atual for um upload anterior, restaura no círculo
setUploadedAvatar(
initialState.avatarUrl.startsWith("data:")
? initialState.avatarUrl
: null,
);
} }
}, [dialogOpen, initialState, resetForm]); }, [dialogOpen, initialState, resetForm]);
@@ -161,6 +224,9 @@ export function PayerDialog({
const submitLabel = const submitLabel =
mode === "create" ? "Salvar pagador" : "Atualizar pagador"; mode === "create" ? "Salvar pagador" : "Atualizar pagador";
const isUploadSelected =
uploadedAvatar !== null && formState.avatarUrl === uploadedAvatar;
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null} {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
@@ -255,6 +321,7 @@ export function PayerDialog({
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{availableAvatars.map((avatar) => { {availableAvatars.map((avatar) => {
const isSelected = avatar === formState.avatarUrl; const isSelected = avatar === formState.avatarUrl;
const src = getAvatarSrc(avatar);
return ( return (
<button <button
type="button" type="button"
@@ -265,7 +332,8 @@ export function PayerDialog({
aria-pressed={isSelected} aria-pressed={isSelected}
> >
<Image <Image
src={getAvatarSrc(avatar)} src={src}
unoptimized={src.startsWith("data:")}
alt={`Avatar ${avatar}`} alt={`Avatar ${avatar}`}
width={40} width={40}
height={40} height={40}
@@ -274,6 +342,43 @@ export function PayerDialog({
</button> </button>
); );
})} })}
{/* Círculo de upload — sempre o último */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingImage}
className="group relative flex items-center justify-center rounded-full p-0.5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 data-[selected=true]:ring-2 data-[selected=true]:ring-primary"
data-selected={isUploadSelected}
aria-pressed={isUploadSelected}
aria-label="Fazer upload de foto"
>
{uploadedAvatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={uploadedAvatar}
alt="Avatar personalizado"
className="size-12 rounded-full object-cover hover:scale-110 transition-transform duration-200"
/>
) : (
<div className="size-12 rounded-full bg-muted border-2 border-dashed border-muted-foreground/20 flex items-center justify-center hover:scale-110 transition-transform duration-200">
{isProcessingImage ? (
<span className="text-[10px] text-muted-foreground animate-pulse">
...
</span>
) : (
<RiImageAddLine className="size-4 text-muted-foreground/50" />
)}
</div>
)}
</button>
</div> </div>
</div> </div>

View File

@@ -17,11 +17,6 @@ import { version } from "@/package.json";
import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog"; import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog"; import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -30,6 +25,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"; } from "@/shared/components/ui/dropdown-menu";
import { Spinner } from "@/shared/components/ui/spinner"; import { Spinner } from "@/shared/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { authClient } from "@/shared/lib/auth/client"; import { authClient } from "@/shared/lib/auth/client";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import type { UpdateCheckResult } from "@/shared/lib/version/check-update"; import type { UpdateCheckResult } from "@/shared/lib/version/check-update";
@@ -68,6 +68,7 @@ export function NavbarUser({
const avatarSrc = pagadorAvatarUrl const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl) ? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null); : user.image || getAvatarSrc(null);
const isDataUrl = avatarSrc.startsWith("data:");
async function handleLogout() { async function handleLogout() {
await authClient.signOut({ await authClient.signOut({
@@ -91,6 +92,7 @@ export function NavbarUser({
<div className="relative size-10 overflow-hidden rounded-full"> <div className="relative size-10 overflow-hidden rounded-full">
<Image <Image
src={avatarSrc} src={avatarSrc}
unoptimized={isDataUrl}
alt={`Avatar de ${user.name}`} alt={`Avatar de ${user.name}`}
fill fill
sizes="40px" sizes="40px"
@@ -112,6 +114,7 @@ export function NavbarUser({
<div className="relative size-9 shrink-0 overflow-hidden rounded-full"> <div className="relative size-9 shrink-0 overflow-hidden rounded-full">
<Image <Image
src={avatarSrc} src={avatarSrc}
unoptimized={isDataUrl}
alt={user.name} alt={user.name}
fill fill
sizes="36px" sizes="36px"
@@ -120,7 +123,9 @@ export function NavbarUser({
</div> </div>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
<span className="text-sm font-medium truncate">{user.name}</span> <span className="text-sm font-medium truncate">
{user.name}
</span>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button

View File

@@ -22,6 +22,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
const avatarSrc = pagadorAvatarUrl const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl) ? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null); : user.image || getAvatarSrc(null);
const isDataUrl = avatarSrc.startsWith("data:");
return ( return (
<SidebarMenu> <SidebarMenu>
@@ -33,6 +34,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
<div className="relative size-8 shrink-0 overflow-hidden rounded-full"> <div className="relative size-8 shrink-0 overflow-hidden rounded-full">
<Image <Image
src={avatarSrc} src={avatarSrc}
unoptimized={isDataUrl}
alt={user.name} alt={user.name}
fill fill
sizes="32px" sizes="32px"

View File

@@ -28,7 +28,7 @@ function AvatarImage({
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn("aspect-square size-full", className)} className={cn("aspect-square size-full object-cover", className)}
{...props} {...props}
/> />
); );