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
@@ -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