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>
This commit is contained in:
1 parent
82b4c8ce17
commit
fcfee6a3f9
17 files changed
+978
-16
No files matched your search
@@ -0,0 +1,54 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user