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 { 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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user