mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user