Files
LambdaMail/services/webmail/src/components/Avatar.tsx
T
Lucas RayanandClaude Opus 5 fcfee6a3f9 feat(avatar): foto de perfil com editor de recorte circular
Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece
na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais
quando nao ha nenhuma.

A geometria vai em funcoes puras com testes, porque e onde os erros moram: a
escala minima cobre o quadrado em vez de caber nele (caber deixa um canto
vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e
preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda
para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao
descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer
comparacao, e propagava-se ate apagar o editor.

O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao,
por isso a foto guardada e tao nitida quanto o original permite em vez de
ficar limitada ao tamanho do editor no ecra.

Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem
pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e
recusado apesar de ser imagem: e um documento que pode levar script e seria
servido de volta a partir da nossa propria origem.

A leitura exige sessao, verificada na propria rota porque o guarda de prefixo
so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde
"esta caixa existe" a quem quer que pergunte, para todos os enderecos que
quiser experimentar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:04:12 -03:00

55 lines
1.9 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { initialsFor } from "../lib/initials";
interface AvatarProps {
address: string | null | undefined;
/** Rendered size in pixels; the square the circle is cut from. */
size: number;
className?: string;
/**
* What to show while there is no photo. Passed in where the caller already
* has a better answer than the address - a display name, usually - so the
* fallback does not get worse than what it replaced.
*/
initials?: string;
}
/**
* Someone's photo, falling back to their initials.
*
* The endpoint answers 404 when there is no photo, which is most people most
* of the time, so the initials are what is rendered first and the image only
* replaces them once it has actually loaded. Doing it the other way round
* shows an empty circle on every row until each request comes back.
*/
export function Avatar({ address, size, className = "", initials }: AvatarProps) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const src = address ? `/api/v1/avatar?address=${encodeURIComponent(address)}` : null;
return (
<span
className={`relative inline-flex flex-none items-center justify-center overflow-hidden rounded-full uppercase ${className}`}
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.38)) }}
aria-hidden="true"
>
{!loaded && <span>{initials ?? initialsFor(address)}</span>}
{src && !failed && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
width={size}
height={size}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
className={`absolute inset-0 h-full w-full object-cover ${loaded ? "" : "opacity-0"}`}
/>
)}
</span>
);
}