Revert "revert: foto de perfil e BIMI, para restaurar o servico"
Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o 502 continuou depois de reverter. A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit anterior, com expose 3000. As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por isso o codigo reposto encontra as tabelas que espera. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e93fa19bdd
commit
c59548e311
26 files changed
+1899
-21
No files matched your search
@@ -113,7 +113,16 @@
|
||||
"passwordResetWarning": "Signs out every session this user holds, including one open right now.",
|
||||
"removeTotp": "Remove second factor",
|
||||
"totpRemoved": "Second factor removed, with its recovery codes.",
|
||||
"tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)"
|
||||
"tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)",
|
||||
"bimiTitle": "BIMI for {domain}",
|
||||
"bimiExplainer": "BIMI is the only mechanism that shows an image beside your mail in external clients. It is per domain, not per user. Gmail and Outlook render it only when the record points at a VMC.",
|
||||
"bimiConfigured": "Logo published",
|
||||
"bimiNotConfigured": "No logo",
|
||||
"bimiNoVmc": "No VMC: Gmail and Outlook will not show this logo.",
|
||||
"bimiSvg": "Logo (SVG Tiny PS)",
|
||||
"bimiVmc": "VMC URL (optional)",
|
||||
"bimiRejected": "The logo does not meet SVG Tiny Portable/Secure:",
|
||||
"bimiSaved": "Logo saved."
|
||||
},
|
||||
"auth": {
|
||||
"adminLoginTitle": "Admin console",
|
||||
@@ -428,5 +437,15 @@
|
||||
"user": "User",
|
||||
"domainAdmin": "Domain admin",
|
||||
"superAdmin": "Super admin"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
"explainer": "This photo appears inside LambdaMail. External services like Gmail and Outlook do not read it: for those, see BIMI on the domains page.",
|
||||
"none": "No photo",
|
||||
"choose": "Choose a photo",
|
||||
"replace": "Replace photo",
|
||||
"zoom": "Zoom",
|
||||
"dragToPosition": "Drag to position",
|
||||
"notAnImage": "That file could not be read as an image."
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,16 @@
|
||||
"passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.",
|
||||
"removeTotp": "Quitar segundo factor",
|
||||
"totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.",
|
||||
"tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)"
|
||||
"tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)",
|
||||
"bimiTitle": "BIMI de {domain}",
|
||||
"bimiExplainer": "BIMI es el unico mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Gmail y Outlook solo lo dibujan cuando el registro apunta a un VMC.",
|
||||
"bimiConfigured": "Logotipo publicado",
|
||||
"bimiNotConfigured": "Sin logotipo",
|
||||
"bimiNoVmc": "Sin VMC: Gmail y Outlook no mostraran este logotipo.",
|
||||
"bimiSvg": "Logotipo (SVG Tiny PS)",
|
||||
"bimiVmc": "URL del VMC (opcional)",
|
||||
"bimiRejected": "El logotipo no cumple SVG Tiny Portable/Secure:",
|
||||
"bimiSaved": "Logotipo guardado."
|
||||
},
|
||||
"auth": {
|
||||
"adminLoginTitle": "Consola de administración",
|
||||
@@ -428,5 +437,15 @@
|
||||
"user": "Usuario",
|
||||
"domainAdmin": "Admin del dominio",
|
||||
"superAdmin": "Super admin"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.",
|
||||
"none": "Sin foto",
|
||||
"choose": "Elegir foto",
|
||||
"replace": "Cambiar foto",
|
||||
"zoom": "Zoom",
|
||||
"dragToPosition": "Arrastre para posicionar",
|
||||
"notAnImage": "No se pudo leer ese archivo como imagen."
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,16 @@
|
||||
"passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.",
|
||||
"removeTotp": "Remover segundo factor",
|
||||
"totpRemoved": "Segundo factor removido, com os codigos de recuperacao.",
|
||||
"tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)"
|
||||
"tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)",
|
||||
"bimiTitle": "BIMI de {domain}",
|
||||
"bimiExplainer": "O BIMI e o unico mecanismo que mostra uma imagem ao lado do seu correio em clientes externos. E por dominio, nao por utilizador. O Gmail e o Outlook so o desenham quando o registo aponta para um VMC.",
|
||||
"bimiConfigured": "Logotipo publicado",
|
||||
"bimiNotConfigured": "Sem logotipo",
|
||||
"bimiNoVmc": "Sem VMC: o Gmail e o Outlook nao vao mostrar este logotipo.",
|
||||
"bimiSvg": "Logotipo (SVG Tiny PS)",
|
||||
"bimiVmc": "URL do VMC (opcional)",
|
||||
"bimiRejected": "O logotipo nao cumpre o SVG Tiny Portable/Secure:",
|
||||
"bimiSaved": "Logotipo guardado."
|
||||
},
|
||||
"auth": {
|
||||
"adminLoginTitle": "Console de administração",
|
||||
@@ -428,5 +437,15 @@
|
||||
"user": "Utilizador",
|
||||
"domainAdmin": "Admin do dominio",
|
||||
"superAdmin": "Super admin"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"explainer": "Esta foto aparece dentro do LambdaMail. Servicos externos como Gmail e Outlook nao a leem: para eles, veja BIMI na pagina de dominios.",
|
||||
"none": "Sem foto",
|
||||
"choose": "Escolher foto",
|
||||
"replace": "Trocar foto",
|
||||
"zoom": "Zoom",
|
||||
"dragToPosition": "Arraste para posicionar",
|
||||
"notAnImage": "Nao foi possivel ler esse ficheiro como imagem."
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,14 @@ export default function AdminDomainsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [reconciling, setReconciling] = useState(false);
|
||||
|
||||
// The BIMI mark for the selected domain: the one thing an external mail
|
||||
// client will ever render for this sender.
|
||||
const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
|
||||
const [bimiSvg, setBimiSvg] = useState("");
|
||||
const [bimiVmc, setBimiVmc] = useState("");
|
||||
const [bimiBusy, setBimiBusy] = useState(false);
|
||||
const [bimiProblems, setBimiProblems] = useState<string[]>([]);
|
||||
const [verification, setVerification] = useState<DnsVerification | null>(null);
|
||||
// What the last reconciliation actually did, so the panel can say it rather
|
||||
// than leaving the operator to infer it from the verification that follows.
|
||||
@@ -118,6 +126,73 @@ export default function AdminDomainsPage() {
|
||||
// Asks the protocols service to resolve every expected record and report
|
||||
// each one. It used to post to the auth service, which has no resolver and
|
||||
// could only re-read the status already in the database.
|
||||
// Reloaded whenever the selected domain changes: the mark belongs to the
|
||||
// domain, not to the console.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setBimi(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
|
||||
const data = res.ok ? await res.json() : { configured: false };
|
||||
if (cancelled) return;
|
||||
setBimi(data);
|
||||
setBimiVmc(data.vmc_url ?? "");
|
||||
setBimiSvg("");
|
||||
setBimiProblems([]);
|
||||
} catch {
|
||||
if (!cancelled) setBimi({ configured: false });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selected?.name]);
|
||||
|
||||
const saveBimi = async () => {
|
||||
if (!selected || !bimiSvg.trim()) return;
|
||||
setBimiBusy(true);
|
||||
setBimiProblems([]);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// Every rule that failed, not the first: a receiver that rejects the
|
||||
// mark shows nothing at all, so one round trip per rule is the worst
|
||||
// possible way to learn this format.
|
||||
setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
|
||||
return;
|
||||
}
|
||||
setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
|
||||
setBimiSvg("");
|
||||
setNotice(t("admin.bimiSaved"));
|
||||
} catch {
|
||||
setError(t("errors.serverError"));
|
||||
} finally {
|
||||
setBimiBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBimi = async () => {
|
||||
if (!selected) return;
|
||||
setBimiBusy(true);
|
||||
try {
|
||||
await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
|
||||
setBimi({ configured: false });
|
||||
setBimiVmc("");
|
||||
} finally {
|
||||
setBimiBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReconcile = async () => {
|
||||
if (!selected) return;
|
||||
setReconciling(true);
|
||||
@@ -446,6 +521,70 @@ export default function AdminDomainsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* BIMI: the only mark an external mail client ever renders for this
|
||||
sender, and the reason a profile photo does not travel. */}
|
||||
{activeTab === "domains" && selected && (
|
||||
<section className={panel}>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||
{t("admin.bimiTitle", { domain: selected.name })}
|
||||
</h2>
|
||||
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Badge variant={bimi?.configured ? "success" : "neutral"}>
|
||||
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
|
||||
</Badge>
|
||||
{bimi?.configured && !bimi.vmc_url && (
|
||||
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
|
||||
<textarea
|
||||
id="bimi-svg"
|
||||
value={bimiSvg}
|
||||
onChange={(e) => setBimiSvg(e.target.value)}
|
||||
rows={7}
|
||||
spellCheck={false}
|
||||
className={`${input} font-mono text-[12px]`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
|
||||
<input
|
||||
id="bimi-vmc"
|
||||
type="url"
|
||||
value={bimiVmc}
|
||||
onChange={(e) => setBimiVmc(e.target.value)}
|
||||
placeholder="https://.../vmc.pem"
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bimiProblems.length > 0 && (
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
|
||||
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
|
||||
{bimiProblems.map((p) => (
|
||||
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
|
||||
{bimiBusy ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
{bimi?.configured && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === "dkim" && (
|
||||
<section className={`${panel} max-w-[720px]`}>
|
||||
<div>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
import { Avatar } from "../../../components/Avatar";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -279,9 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
|
||||
>
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate text-[12.5px] leading-tight"
|
||||
|
||||
@@ -38,6 +38,7 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
|
||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||
import { Button } from "../../../../../components/ui/Button";
|
||||
import { Avatar } from "../../../../../components/Avatar";
|
||||
|
||||
interface MessageSummary {
|
||||
uid: number;
|
||||
@@ -457,16 +458,16 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
data-active={isSelected}
|
||||
className="lm-row w-full text-left"
|
||||
>
|
||||
<span
|
||||
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
|
||||
<Avatar
|
||||
address={msg.sender_address}
|
||||
initials={initials(msg.from_display_name, msg.sender_address)}
|
||||
size={38}
|
||||
className={
|
||||
msg.seen
|
||||
? "bg-dark-card text-slate-400"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(msg.from_display_name, msg.sender_address)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||
<span className="flex items-baseline gap-2">
|
||||
@@ -652,9 +653,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
</h1>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||
</div>
|
||||
<Avatar
|
||||
address={selectedRow?.sender_address ?? selected.from}
|
||||
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||
size={38}
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<span className="break-all text-sm text-slate-100">{selected.from}</span>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Upload, ZoomIn, ZoomOut, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
import {
|
||||
clampTransform,
|
||||
initialTransform,
|
||||
scaleRange,
|
||||
sourceRect,
|
||||
type Size,
|
||||
type Transform,
|
||||
} from "../lib/avatarCrop";
|
||||
|
||||
/** The frame is square; the mask over it is what makes the photo a circle. */
|
||||
const VIEWPORT = 288;
|
||||
/** What gets stored. Larger than any place it is shown, so it stays sharp. */
|
||||
const EXPORT_SIZE = 512;
|
||||
|
||||
interface AvatarEditorProps {
|
||||
/** Current photo, if there is one. */
|
||||
src: string | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function AvatarEditor({ src, onSaved }: AvatarEditorProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [natural, setNatural] = useState<Size>({ width: 0, height: 0 });
|
||||
const [transform, setTransform] = useState<Transform>({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragRef = useRef<{ x: number; y: number; from: Transform } | null>(null);
|
||||
|
||||
/** Draws the current transform into the preview. */
|
||||
const paint = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, VIEWPORT, VIEWPORT);
|
||||
const r = sourceRect(natural, VIEWPORT, transform);
|
||||
ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, VIEWPORT, VIEWPORT);
|
||||
}, [image, natural, transform]);
|
||||
|
||||
useEffect(() => {
|
||||
paint();
|
||||
}, [paint]);
|
||||
|
||||
const openFile = (file: File) => {
|
||||
setError(null);
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const size = { width: img.naturalWidth, height: img.naturalHeight };
|
||||
setImage(img);
|
||||
setNatural(size);
|
||||
setTransform(initialTransform(size, VIEWPORT));
|
||||
// The bitmap is decoded and held; the object URL has done its job and
|
||||
// would otherwise keep the file alive for the life of the page.
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
setError(t("avatar.notAnImage"));
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!image) return;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, from: transform };
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !image) return;
|
||||
setTransform(
|
||||
clampTransform(natural, VIEWPORT, {
|
||||
scale: drag.from.scale,
|
||||
offsetX: drag.from.offsetX + (e.clientX - drag.x),
|
||||
offsetY: drag.from.offsetY + (e.clientY - drag.y),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const zoomTo = (scale: number) => {
|
||||
if (!image) return;
|
||||
setTransform((prev) => clampTransform(natural, VIEWPORT, { ...prev, scale }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!image) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Exported from the source pixels rather than from the preview, so the
|
||||
// stored photo is as sharp as the original allows instead of being
|
||||
// limited to the size of the editor on screen.
|
||||
const out = document.createElement("canvas");
|
||||
out.width = EXPORT_SIZE;
|
||||
out.height = EXPORT_SIZE;
|
||||
const ctx = out.getContext("2d");
|
||||
if (!ctx) throw new Error();
|
||||
const r = sourceRect(natural, VIEWPORT, transform);
|
||||
ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
// The frame is always fully covered, so there is no transparency to
|
||||
// preserve and JPEG is a fraction of the size.
|
||||
out.toBlob((b) => resolve(b), "image/jpeg", 0.9),
|
||||
);
|
||||
if (!blob) throw new Error();
|
||||
|
||||
const res = await fetch("/api/v1/user/avatar", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message);
|
||||
}
|
||||
setImage(null);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/user/avatar", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
onSaved();
|
||||
} catch {
|
||||
setError(t("errors.serverError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const range = scaleRange(natural, VIEWPORT);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!image ? (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* What is stored today, shown as it will appear elsewhere. */}
|
||||
<span className="flex h-[88px] w-[88px] flex-none items-center justify-center overflow-hidden rounded-full bg-dark-card shadow-edge">
|
||||
{src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-500">{t("avatar.none")}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span>{src ? t("avatar.replace") : t("avatar.choose")}</span>
|
||||
</Button>
|
||||
{src && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void remove()} disabled={busy}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>{t("common.delete")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-full shadow-edge"
|
||||
style={{ width: VIEWPORT, height: VIEWPORT }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={VIEWPORT}
|
||||
height={VIEWPORT}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className="block cursor-grab touch-none active:cursor-grabbing"
|
||||
aria-label={t("avatar.dragToPosition")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[288px] items-center gap-2.5">
|
||||
<ZoomOut className="h-4 w-4 flex-none text-slate-400" />
|
||||
<input
|
||||
type="range"
|
||||
min={range.min}
|
||||
max={range.max}
|
||||
step={(range.max - range.min) / 200 || 0.01}
|
||||
value={transform.scale}
|
||||
onChange={(e) => zoomTo(Number(e.target.value))}
|
||||
aria-label={t("avatar.zoom")}
|
||||
className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-dark-card accent-indigo-500"
|
||||
/>
|
||||
<ZoomIn className="h-4 w-4 flex-none text-slate-400" />
|
||||
</div>
|
||||
|
||||
<p className="text-[11.5px] text-slate-500">{t("avatar.dragToPosition")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setImage(null)} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => void save()} disabled={busy}>
|
||||
{busy ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-rose-900/60 px-3.5 py-2.5 text-[12.5px] text-rose-200 shadow-edge">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) openFile(file);
|
||||
// Cleared so choosing the same file twice in a row still fires.
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
minimumScale,
|
||||
scaleRange,
|
||||
clampTransform,
|
||||
initialTransform,
|
||||
sourceRect,
|
||||
} from "./avatarCrop";
|
||||
|
||||
const VIEWPORT = 320;
|
||||
|
||||
describe("minimumScale", () => {
|
||||
// Cover, not contain. Scaling a wide photo to fit its width leaves the top
|
||||
// and bottom of the circle empty.
|
||||
it("covers the frame on the short side of a wide image", () => {
|
||||
expect(minimumScale({ width: 800, height: 400 }, VIEWPORT)).toBeCloseTo(320 / 400);
|
||||
});
|
||||
|
||||
it("covers the frame on the short side of a tall image", () => {
|
||||
expect(minimumScale({ width: 400, height: 800 }, VIEWPORT)).toBeCloseTo(320 / 400);
|
||||
});
|
||||
|
||||
it("scales a small image up rather than leaving it small", () => {
|
||||
expect(minimumScale({ width: 100, height: 100 }, VIEWPORT)).toBeCloseTo(3.2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ width: 0, height: 100 }],
|
||||
[{ width: 100, height: 0 }],
|
||||
[{ width: Number.NaN, height: 100 }],
|
||||
])("survives a degenerate image %o", (image) => {
|
||||
expect(Number.isFinite(minimumScale(image, VIEWPORT))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampTransform", () => {
|
||||
const image = { width: 800, height: 600 };
|
||||
|
||||
it("refuses to zoom below covering the frame", () => {
|
||||
const t = clampTransform(image, VIEWPORT, { scale: 0.01, offsetX: 0, offsetY: 0 });
|
||||
expect(t.scale).toBeCloseTo(minimumScale(image, VIEWPORT));
|
||||
});
|
||||
|
||||
// Without this, dragging far enough pulls the edge of the photo into the
|
||||
// circle and the export carries a transparent wedge.
|
||||
it("stops the image at its own edge", () => {
|
||||
const scale = minimumScale(image, VIEWPORT);
|
||||
const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 });
|
||||
|
||||
const overhangX = (image.width * scale - VIEWPORT) / 2;
|
||||
expect(t.offsetX).toBeCloseTo(overhangX);
|
||||
// At the covering scale the short side has no slack at all, so it cannot
|
||||
// move: any movement there would expose a gap.
|
||||
expect(t.offsetY).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("allows movement on both axes once zoomed in", () => {
|
||||
const scale = minimumScale(image, VIEWPORT) * 2;
|
||||
const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 });
|
||||
expect(t.offsetX).toBeGreaterThan(0);
|
||||
expect(t.offsetY).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("caps the zoom so the photo cannot be magnified into mush", () => {
|
||||
const { max } = scaleRange(image, VIEWPORT);
|
||||
expect(clampTransform(image, VIEWPORT, { scale: 9999, offsetX: 0, offsetY: 0 }).scale).toBeCloseTo(max);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sourceRect", () => {
|
||||
const image = { width: 800, height: 600 };
|
||||
|
||||
it("is always square, because the frame is", () => {
|
||||
const r = sourceRect(image, VIEWPORT, { scale: 1.2, offsetX: 30, offsetY: -20 });
|
||||
expect(r.width).toBeCloseTo(r.height);
|
||||
});
|
||||
|
||||
// At the covering scale the whole short side is used: nothing of the height
|
||||
// is cropped away, and the crop is the full 600px of it.
|
||||
it("takes the whole short side at the covering scale", () => {
|
||||
const r = sourceRect(image, VIEWPORT, initialTransform(image, VIEWPORT));
|
||||
expect(r.height).toBeCloseTo(600);
|
||||
expect(r.y).toBeCloseTo(0);
|
||||
// Centred horizontally: (800 - 600) / 2.
|
||||
expect(r.x).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("never reads outside the image", () => {
|
||||
for (const wanted of [
|
||||
{ scale: 0.001, offsetX: -9999, offsetY: -9999 },
|
||||
{ scale: 9999, offsetX: 9999, offsetY: 9999 },
|
||||
{ scale: 1, offsetX: 0, offsetY: 9999 },
|
||||
]) {
|
||||
const r = sourceRect(image, VIEWPORT, wanted);
|
||||
expect(r.x).toBeGreaterThanOrEqual(-0.001);
|
||||
expect(r.y).toBeGreaterThanOrEqual(-0.001);
|
||||
expect(r.x + r.width).toBeLessThanOrEqual(image.width + 0.001);
|
||||
expect(r.y + r.height).toBeLessThanOrEqual(image.height + 0.001);
|
||||
}
|
||||
});
|
||||
|
||||
// Dragging the image right shows what is further left in the source.
|
||||
it("moves the crop opposite to the drag", () => {
|
||||
const scale = minimumScale(image, VIEWPORT) * 2;
|
||||
const left = sourceRect(image, VIEWPORT, { scale, offsetX: 40, offsetY: 0 });
|
||||
const centre = sourceRect(image, VIEWPORT, { scale, offsetX: 0, offsetY: 0 });
|
||||
expect(left.x).toBeLessThan(centre.x);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The geometry behind the circular photo editor.
|
||||
*
|
||||
* The editor shows a fixed square viewport with a circular mask; the image sits
|
||||
* behind it, scaled and dragged. Everything here works in viewport pixels and
|
||||
* is deliberately free of DOM and canvas, so the awkward parts - the smallest
|
||||
* scale that still covers the circle, and clamping so no gap can be dragged
|
||||
* into view - are testable on their own.
|
||||
*/
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Where the image sits behind the viewport, and how big it is drawn. */
|
||||
export interface Transform {
|
||||
/** Multiplier applied to the image's natural size. */
|
||||
scale: number;
|
||||
/** Image centre, relative to the viewport centre, in viewport pixels. */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest scale at which the image still covers the whole viewport.
|
||||
*
|
||||
* Below this a corner of the frame is empty, and the exported photo would carry
|
||||
* a transparent wedge - so this is the floor for the zoom control, not merely a
|
||||
* suggestion.
|
||||
*/
|
||||
export function minimumScale(image: Size, viewport: number): number {
|
||||
// Finite, not merely positive: NaN fails every comparison, so `<= 0` lets it
|
||||
// through and it then propagates into the transform and blanks the editor.
|
||||
// A NaN dimension is what an image that failed to decode reports.
|
||||
if (
|
||||
!Number.isFinite(image.width) || !Number.isFinite(image.height) || !Number.isFinite(viewport) ||
|
||||
image.width <= 0 || image.height <= 0 || viewport <= 0
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
// Cover, not contain: the larger of the two ratios.
|
||||
return Math.max(viewport / image.width, viewport / image.height);
|
||||
}
|
||||
|
||||
/** The zoom control's range: from covering the frame to four times that. */
|
||||
export function scaleRange(image: Size, viewport: number): { min: number; max: number } {
|
||||
const min = minimumScale(image, viewport);
|
||||
return { min, max: min * 4 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the image over the viewport, whatever was asked for.
|
||||
*
|
||||
* A drag or a zoom out can otherwise pull an edge inside the frame. Clamping
|
||||
* the offset to the overhang keeps the frame covered without fighting the
|
||||
* gesture: the image stops at its edge instead of springing back.
|
||||
*/
|
||||
export function clampTransform(image: Size, viewport: number, wanted: Transform): Transform {
|
||||
const { min, max } = scaleRange(image, viewport);
|
||||
const scale = Math.min(Math.max(wanted.scale, min), max);
|
||||
|
||||
// Half of what sticks out past the viewport on each axis.
|
||||
const overhangX = Math.max(0, (image.width * scale - viewport) / 2);
|
||||
const overhangY = Math.max(0, (image.height * scale - viewport) / 2);
|
||||
|
||||
return {
|
||||
scale,
|
||||
offsetX: Math.min(Math.max(wanted.offsetX, -overhangX), overhangX),
|
||||
offsetY: Math.min(Math.max(wanted.offsetY, -overhangY), overhangY),
|
||||
};
|
||||
}
|
||||
|
||||
/** The transform that centres the image at the smallest covering scale. */
|
||||
export function initialTransform(image: Size, viewport: number): Transform {
|
||||
return { scale: minimumScale(image, viewport), offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle of the *source* image that the viewport is showing.
|
||||
*
|
||||
* This is what the export draws. Working back to source pixels rather than
|
||||
* scaling the preview means the saved photo is as sharp as the original
|
||||
* allows, instead of being limited to the size of the editor on screen.
|
||||
*/
|
||||
export interface SourceRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function sourceRect(image: Size, viewport: number, transform: Transform): SourceRect {
|
||||
const t = clampTransform(image, viewport, transform);
|
||||
// One viewport pixel is this many source pixels.
|
||||
const perPixel = 1 / t.scale;
|
||||
const size = viewport * perPixel;
|
||||
|
||||
// The viewport centre, expressed in source coordinates.
|
||||
const centreX = image.width / 2 - t.offsetX * perPixel;
|
||||
const centreY = image.height / 2 - t.offsetY * perPixel;
|
||||
|
||||
return {
|
||||
x: centreX - size / 2,
|
||||
y: centreY - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user