mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Restructure
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
export function CropperDialog() {
|
||||
return (
|
||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "../../../../../core/types";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
onCancel: () => void;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (imageFile) {
|
||||
const reader = new FileReader();
|
||||
|
||||
function handleImageLoad() {
|
||||
setIsLoaded(true);
|
||||
// Initialize crop area to center of image
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
|
||||
setCropArea({
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleReaderLoad() {
|
||||
if (imageRef.current) {
|
||||
setSrc(reader.result as string);
|
||||
imageRef.current.addEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
|
||||
reader.addEventListener("load", handleReaderLoad);
|
||||
reader.readAsDataURL(imageFile);
|
||||
|
||||
return () => {
|
||||
reader.abort();
|
||||
reader.removeEventListener("load", handleReaderLoad);
|
||||
imageRef.current?.removeEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
}, [imageFile]);
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if click is within crop area
|
||||
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
|
||||
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (isDragging && isLoaded && rect && imageRef.current) {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
x - dragStart.x,
|
||||
imageRef.current.naturalWidth - cropArea.width
|
||||
)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
y - dragStart.y,
|
||||
imageRef.current.naturalHeight - cropArea.height
|
||||
)
|
||||
);
|
||||
|
||||
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
function handleCrop() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to crop area
|
||||
canvas.width = cropArea.width;
|
||||
canvas.height = cropArea.height;
|
||||
|
||||
// Draw cropped portion
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, cropArea.width, cropArea.height
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onCrop(croppedImageData);
|
||||
};
|
||||
|
||||
function drawCropArea() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw crop overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
|
||||
// Draw crop border
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded]);
|
||||
|
||||
if (!imageFile) return null;
|
||||
|
||||
return (
|
||||
<div className="cropper-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
style={{ display: 'none' }}
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
|
||||
Обрезать
|
||||
</mdui-button>
|
||||
<mdui-button variant="outlined" onClick={onCancel}>
|
||||
Отмена
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../../../images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../../../core/types";
|
||||
import { MaterialDialog } from "../../../../../core/components/Dialog";
|
||||
import useProfile from "../../../hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "../../../../../core/components/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
|
||||
const [username, setUsername] = useState(profileData?.nickname ?? "");
|
||||
const [description, setDescription] = useState(profileData?.description ?? "");
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await updateProfileData({
|
||||
nickname: username.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedImageData: string) => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const success = await uploadProfilePictureData(blob);
|
||||
if (success) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img
|
||||
id="profile-picture"
|
||||
src={profilePictureUrl}
|
||||
alt="Ваше фото"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<mdui-button-icon
|
||||
icon="camera_alt--filled"
|
||||
id="upload-pfp-btn"
|
||||
className="upload-overlay"
|
||||
variant="filled"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="pfp-file-input"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
id="username-field"
|
||||
label="Имя пользователя"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username"
|
||||
disabled={isLoading || isUpdating} />
|
||||
</div>
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<MaterialTextField
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"
|
||||
disabled={isLoading || isUpdating} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button
|
||||
type="submit"
|
||||
id="profile-submit"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
id="profile-dialog-close"
|
||||
variant="outlined"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Закрыть
|
||||
</mdui-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Image Cropper Dialog */}
|
||||
<MaterialDialog
|
||||
id="cropper-dialog"
|
||||
close-on-overlay-click
|
||||
close-on-esc
|
||||
open={showCropper}
|
||||
onOpenChange={setShowCropper}
|
||||
>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<ImageCropper
|
||||
imageFile={selectedImage}
|
||||
onCrop={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user