web: add 500x500 avatar cropper for profile and chat uploads
All checks were successful
CI / test (push) Successful in 28s

This commit is contained in:
2026-03-08 14:17:19 +03:00
parent 07e970e81f
commit 528778238b
3 changed files with 264 additions and 2 deletions

View File

@@ -0,0 +1,240 @@
import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { createPortal } from "react-dom";
interface AvatarCropModalProps {
open: boolean;
file: File | null;
onCancel: () => void;
onApply: (file: File) => void;
}
const VIEWPORT_SIZE = 320;
const OUTPUT_SIZE = 500;
const MIN_ZOOM = 1;
const MAX_ZOOM = 4;
export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [processing, setProcessing] = useState(false);
const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
useEffect(() => {
if (!open || !file) {
setImageUrl(null);
setImageEl(null);
setZoom(1);
setPosition({ x: 0, y: 0 });
return;
}
const url = URL.createObjectURL(file);
setImageUrl(url);
setImageEl(null);
setZoom(1);
setPosition({ x: 0, y: 0 });
return () => {
URL.revokeObjectURL(url);
};
}, [file, open]);
const baseScale = useMemo(() => {
if (!imageEl) {
return 1;
}
return Math.max(VIEWPORT_SIZE / imageEl.naturalWidth, VIEWPORT_SIZE / imageEl.naturalHeight);
}, [imageEl]);
const scale = baseScale * zoom;
useEffect(() => {
if (!imageEl) {
return;
}
const width = imageEl.naturalWidth * baseScale;
const height = imageEl.naturalHeight * baseScale;
setPosition(clampPosition({ x: (VIEWPORT_SIZE - width) / 2, y: (VIEWPORT_SIZE - height) / 2 }, width, height));
}, [baseScale, imageEl]);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !processing) {
onCancel();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel, open, processing]);
if (!open || !file) {
return null;
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (!imageEl || processing) {
return;
}
event.preventDefault();
dragRef.current = {
startX: event.clientX,
startY: event.clientY,
startPosX: position.x,
startPosY: position.y,
};
(event.currentTarget as HTMLDivElement).setPointerCapture(event.pointerId);
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
if (!imageEl || !dragRef.current || processing) {
return;
}
const dx = event.clientX - dragRef.current.startX;
const dy = event.clientY - dragRef.current.startY;
const width = imageEl.naturalWidth * scale;
const height = imageEl.naturalHeight * scale;
setPosition(clampPosition({ x: dragRef.current.startPosX + dx, y: dragRef.current.startPosY + dy }, width, height));
}
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
dragRef.current = null;
if ((event.currentTarget as HTMLDivElement).hasPointerCapture(event.pointerId)) {
(event.currentTarget as HTMLDivElement).releasePointerCapture(event.pointerId);
}
}
function handleZoomChange(nextZoom: number) {
if (!imageEl) {
setZoom(nextZoom);
return;
}
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom));
const nextScale = baseScale * clamped;
const prevScale = scale;
const cx = VIEWPORT_SIZE / 2;
const cy = VIEWPORT_SIZE / 2;
const nextX = cx - ((cx - position.x) / prevScale) * nextScale;
const nextY = cy - ((cy - position.y) / prevScale) * nextScale;
const width = imageEl.naturalWidth * nextScale;
const height = imageEl.naturalHeight * nextScale;
setZoom(clamped);
setPosition(clampPosition({ x: nextX, y: nextY }, width, height));
}
async function handleApply() {
if (!imageEl || !file || processing) {
return;
}
setProcessing(true);
try {
const sourceX = Math.max(0, Math.min(imageEl.naturalWidth, (-position.x) / scale));
const sourceY = Math.max(0, Math.min(imageEl.naturalHeight, (-position.y) / scale));
const sourceSize = Math.min(imageEl.naturalWidth - sourceX, imageEl.naturalHeight - sourceY, VIEWPORT_SIZE / scale);
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) {
setProcessing(false);
return;
}
ctx.drawImage(imageEl, sourceX, sourceY, sourceSize, sourceSize, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((value) => resolve(value), "image/jpeg", 0.86);
});
if (!blob) {
setProcessing(false);
return;
}
const safeName = file.name.replace(/\.[^.]+$/, "").slice(0, 48) || "avatar";
const output = new File([blob], `${safeName}_avatar.jpg`, { type: "image/jpeg" });
onApply(output);
} finally {
setProcessing(false);
}
}
return createPortal(
<div className="fixed inset-0 z-[220] bg-slate-950/70 backdrop-blur-sm" onClick={() => (processing ? null : onCancel())}>
<div className="absolute inset-0 flex items-center justify-center p-4" onClick={(event) => event.stopPropagation()}>
<div className="w-full max-w-lg rounded-2xl border border-slate-700/80 bg-slate-900/95 p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-100">Crop avatar</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-100 disabled:opacity-60" disabled={processing} onClick={onCancel} type="button">
Cancel
</button>
</div>
<div className="mx-auto mb-3 h-[320px] w-[320px] overflow-hidden rounded-xl border border-slate-700 bg-slate-950">
<div
className="relative h-full w-full cursor-grab select-none active:cursor-grabbing"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{imageUrl ? (
<img
alt="avatar crop"
className="pointer-events-none absolute left-0 top-0 select-none"
draggable={false}
onLoad={(event) => {
setImageEl(event.currentTarget);
}}
src={imageUrl}
style={{
width: imageEl ? imageEl.naturalWidth * scale : "auto",
height: imageEl ? imageEl.naturalHeight * scale : "auto",
transform: `translate(${position.x}px, ${position.y}px)`,
transformOrigin: "top left",
}}
/>
) : null}
</div>
</div>
<div className="mb-4 flex items-center gap-3">
<span className="w-12 text-xs text-slate-400">Zoom</span>
<input
className="w-full"
max={MAX_ZOOM}
min={MIN_ZOOM}
onChange={(event) => handleZoomChange(Number(event.target.value))}
step={0.01}
type="range"
value={zoom}
/>
<span className="w-12 text-right text-xs text-slate-300">{zoom.toFixed(2)}x</span>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400">Output: 500x500 JPEG</p>
<button
className="rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950 disabled:opacity-60"
disabled={!imageEl || processing}
onClick={() => void handleApply()}
type="button"
>
{processing ? "Processing..." : "Apply"}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
function clampPosition(pos: { x: number; y: number }, width: number, height: number) {
const minX = Math.min(0, VIEWPORT_SIZE - width);
const maxX = 0;
const minY = Math.min(0, VIEWPORT_SIZE - height);
const maxY = 0;
return {
x: Math.max(minX, Math.min(maxX, pos.x)),
y: Math.max(minY, Math.min(maxY, pos.y)),
};
}

View File

@@ -21,6 +21,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore"; import { useUiStore } from "../store/uiStore";
import { AvatarCropModal } from "./AvatarCropModal";
import { MediaViewer } from "./MediaViewer"; import { MediaViewer } from "./MediaViewer";
interface Props { interface Props {
@@ -58,6 +59,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null); const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null);
const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all"); const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all");
const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null); const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null);
const [avatarCropFile, setAvatarCropFile] = useState<File | null>(null);
const myRole = useMemo(() => { const myRole = useMemo(() => {
if (chat?.my_role) { if (chat?.my_role) {
@@ -314,7 +316,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
if (!file) { if (!file) {
return; return;
} }
void uploadChatAvatar(file); setAvatarCropFile(file);
}} }}
type="file" type="file"
/> />
@@ -821,6 +823,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
open open
/> />
) : null} ) : null}
<AvatarCropModal
file={avatarCropFile}
onApply={(processedFile) => {
setAvatarCropFile(null);
void uploadChatAvatar(processedFile);
}}
onCancel={() => setAvatarCropFile(null)}
open={Boolean(avatarCropFile)}
/>
</div>, </div>,
document.body document.body
); );

View File

@@ -9,6 +9,7 @@ import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useUiStore } from "../store/uiStore"; import { useUiStore } from "../store/uiStore";
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences"; import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
import { AvatarCropModal } from "./AvatarCropModal";
type SettingsPage = "main" | "general" | "notifications" | "privacy"; type SettingsPage = "main" | "general" | "notifications" | "privacy";
@@ -45,6 +46,7 @@ export function SettingsPanel({ open, onClose }: Props) {
avatarUrl: "", avatarUrl: "",
}); });
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false); const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
const [avatarCropFile, setAvatarCropFile] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
if (!me) { if (!me) {
@@ -244,7 +246,7 @@ export function SettingsPanel({ open, onClose }: Props) {
if (!file) { if (!file) {
return; return;
} }
void uploadAvatar(file); setAvatarCropFile(file);
}} }}
type="file" type="file"
/> />
@@ -605,6 +607,15 @@ export function SettingsPanel({ open, onClose }: Props) {
) : null} ) : null}
</div> </div>
</aside> </aside>
<AvatarCropModal
file={avatarCropFile}
onApply={(processedFile) => {
setAvatarCropFile(null);
void uploadAvatar(processedFile);
}}
onCancel={() => setAvatarCropFile(null)}
open={Boolean(avatarCropFile)}
/>
</div>, </div>,
document.body document.body
); );