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>
55 lines
1.9 KiB
TypeScript
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>
|
|
);
|
|
}
|