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
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
Lock,
|
||||
Languages,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
@@ -24,6 +25,8 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
||||
import { useFolders } from "../../../../lib/useFolders";
|
||||
import { AvatarEditor } from "../../../../components/AvatarEditor";
|
||||
import { useAccount } from "../../../../lib/useAccount";
|
||||
import { moveTargets } from "../../../../lib/mailCounts";
|
||||
|
||||
interface SieveRule {
|
||||
@@ -51,11 +54,20 @@ interface WebSession {
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
|
||||
type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
const t = useTranslations();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
|
||||
// Bumped after a save so the browser refetches instead of showing the photo
|
||||
// it already holds under the same URL.
|
||||
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||
const account = useAccount("user");
|
||||
// Null until the account loads, so the editor is not told "no photo" before
|
||||
// it is known whether there is one.
|
||||
const avatarSrc = account?.email
|
||||
? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}`
|
||||
: null;
|
||||
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
||||
@@ -285,6 +297,7 @@ export default function UserSettingsPage() {
|
||||
separated by everything else. The same panels now sit behind a tab strip,
|
||||
in the order the redesign groups them. */
|
||||
const tabs = [
|
||||
{ id: "profile" as const, label: t("avatar.title"), icon: UserRound },
|
||||
{ id: "security" as const, label: t("settings.security"), icon: Shield },
|
||||
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
|
||||
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
|
||||
@@ -336,6 +349,21 @@ export default function UserSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "profile" && (
|
||||
<section className={panel}>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("avatar.title")}</h2>
|
||||
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("avatar.explainer")}</p>
|
||||
<AvatarEditor
|
||||
src={avatarSrc}
|
||||
onSaved={() => {
|
||||
// The query string changes so the browser fetches the new
|
||||
// photo instead of the one it already has under this URL.
|
||||
setAvatarVersion((v) => v + 1);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === "security" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 2FA TOTP SECTION */}
|
||||
|
||||
Reference in new issue
Block a user