web: add 500x500 avatar cropper for profile and chat uploads
All checks were successful
CI / test (push) Successful in 28s
All checks were successful
CI / test (push) Successful in 28s
This commit is contained in:
240
web/src/components/AvatarCropModal.tsx
Normal file
240
web/src/components/AvatarCropModal.tsx
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { AvatarCropModal } from "./AvatarCropModal";
|
||||
import { MediaViewer } from "./MediaViewer";
|
||||
|
||||
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 [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 [avatarCropFile, setAvatarCropFile] = useState<File | null>(null);
|
||||
|
||||
const myRole = useMemo(() => {
|
||||
if (chat?.my_role) {
|
||||
@@ -314,7 +316,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void uploadChatAvatar(file);
|
||||
setAvatarCropFile(file);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
@@ -821,6 +823,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
open
|
||||
/>
|
||||
) : null}
|
||||
<AvatarCropModal
|
||||
file={avatarCropFile}
|
||||
onApply={(processedFile) => {
|
||||
setAvatarCropFile(null);
|
||||
void uploadChatAvatar(processedFile);
|
||||
}}
|
||||
onCancel={() => setAvatarCropFile(null)}
|
||||
open={Boolean(avatarCropFile)}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AuthSession, AuthUser } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
|
||||
import { AvatarCropModal } from "./AvatarCropModal";
|
||||
|
||||
type SettingsPage = "main" | "general" | "notifications" | "privacy";
|
||||
|
||||
@@ -45,6 +46,7 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
avatarUrl: "",
|
||||
});
|
||||
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
|
||||
const [avatarCropFile, setAvatarCropFile] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
@@ -244,7 +246,7 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void uploadAvatar(file);
|
||||
setAvatarCropFile(file);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
@@ -605,6 +607,15 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
<AvatarCropModal
|
||||
file={avatarCropFile}
|
||||
onApply={(processedFile) => {
|
||||
setAvatarCropFile(null);
|
||||
void uploadAvatar(processedFile);
|
||||
}}
|
||||
onCancel={() => setAvatarCropFile(null)}
|
||||
open={Boolean(avatarCropFile)}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user