From e11240f78f83f67ff761b6c90b75756a61e2babb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 5 Apr 2026 01:27:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B2=D0=B0=D1=82=D0=B0=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/Auth/DTOs/AuthResponseDto.cs | 3 +- .../Auth/Application/Users/GetMe/GetMe.cs | 3 +- backend/src/Modules/Auth/Domain/User.cs | 3 ++ .../Persistence/UserRepository.cs | 1 + .../Application/Profiles/Avatar/CropAvatar.cs | 21 +++++----- .../Profiles/Avatar/UploadAvatar.cs | 30 ++++++++++++- .../Database/ProfileRepository.cs | 22 ++++------ .../Endpoints/ProfilesEndpoints.cs | 4 +- client-web/src/core/utils/cropImage.ts | 42 +++++++++++++++++++ client-web/src/core/utils/utils.ts | 5 +-- .../modules/users/infrastructure/userApi.ts | 4 +- .../components/AvatarCropModal.tsx | 11 +++-- .../presentation/components/SettingsPage.tsx | 31 +++++++++----- 13 files changed, 133 insertions(+), 47 deletions(-) create mode 100644 client-web/src/core/utils/cropImage.ts diff --git a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthResponseDto.cs b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthResponseDto.cs index f0a7ada..3777aa1 100644 --- a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthResponseDto.cs +++ b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthResponseDto.cs @@ -1,4 +1,4 @@ -namespace Knot.Contracts.Auth.Application.Auth.DTOs; +namespace Knot.Contracts.Auth.Application.Auth.DTOs; public class AuthResponseDto { @@ -7,6 +7,7 @@ public class AuthResponseDto public Guid UserId { get; set; } public string Username { get; set; } = string.Empty; public string? DisplayName { get; set; } + public string? Avatar { get; set; } } public class ResetPasswordDto diff --git a/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs b/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs index b817485..57f6dd5 100644 --- a/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs +++ b/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs @@ -29,7 +29,8 @@ internal sealed class GetMeQueryHandler : IQueryHandler Username = contract.Username; DisplayName = contract.DisplayName; PhoneNumber = contract.PhoneNumber; + Bio = contract.Bio; + Avatar = contract.Avatar; + Birthday = contract.Birthday; IsBanned = contract.IsBanned; BannedUntil = contract.BannedUntil; SetOnline(contract.IsOnline, contract.LastSeen); diff --git a/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs b/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs index eec410a..1f82a7e 100644 --- a/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs +++ b/backend/src/Modules/Auth/Infrastructure/Persistence/UserRepository.cs @@ -65,6 +65,7 @@ internal sealed class UserRepository : IUserRepository { domainUser.UpdateFromContract(user); _context.Users.Update(domainUser); + await _context.SaveChangesAsync(cancellationToken); } } diff --git a/backend/src/Modules/Profiles/Application/Profiles/Avatar/CropAvatar.cs b/backend/src/Modules/Profiles/Application/Profiles/Avatar/CropAvatar.cs index 2020b00..0d4029a 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/Avatar/CropAvatar.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/Avatar/CropAvatar.cs @@ -1,12 +1,7 @@ -using Knot.Shared.Kernel; using Knot.Contracts.Profiles.Domain; using Knot.Contracts.Profiles.Application.DTOs; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace Knot.Modules.Profiles.Application.Profiles.Avatar; @@ -15,7 +10,8 @@ public sealed record CropAvatarCommand( Stream FileStream, string FileName, string ContentType, - int X, int Y, int Width, int Height) : ICommand; + int X, int Y, int Width, int Height, + int SourceWidth, int SourceHeight) : ICommand; internal sealed class CropAvatarCommandHandler : ICommandHandler { @@ -53,11 +49,16 @@ internal sealed class CropAvatarCommandHandler : ICommandHandler CropAndResizeAsync(CropAvatarCommand request, CancellationToken ct) { using var image = await Image.LoadAsync(request.FileStream, ct); + image.Mutate(x => x.AutoOrient()); - var startX = Math.Clamp(request.X, 0, image.Width - 1); - var startY = Math.Clamp(request.Y, 0, image.Height - 1); - var width = Math.Clamp(request.Width, 1, image.Width - startX); - var height = Math.Clamp(request.Height, 1, image.Height - startY); + // Рассчитываем коэффициент масштабирования между тем, что видел фронтенд и тем, что загрузил бэкенд + double scaleX = (double)image.Width / request.SourceWidth; + double scaleY = (double)image.Height / request.SourceHeight; + + var startX = Math.Clamp((int)(request.X * scaleX), 0, image.Width - 1); + var startY = Math.Clamp((int)(request.Y * scaleY), 0, image.Height - 1); + var width = Math.Clamp((int)(request.Width * scaleX), 1, image.Width - startX); + var height = Math.Clamp((int)(request.Height * scaleY), 1, image.Height - startY); image.Mutate(ctx => ctx .Crop(new Rectangle(startX, startY, width, height)) diff --git a/backend/src/Modules/Profiles/Application/Profiles/Avatar/UploadAvatar.cs b/backend/src/Modules/Profiles/Application/Profiles/Avatar/UploadAvatar.cs index 63383ed..21b3990 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/Avatar/UploadAvatar.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/Avatar/UploadAvatar.cs @@ -18,11 +18,16 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler> Handle(UploadAvatarCommand request, CancellationToken cancellationToken) @@ -37,6 +42,14 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken) @@ -68,6 +86,14 @@ internal sealed class DeleteAvatarCommandHandler : ICommandHandler> UpdateAsync(UserProfileDto dto, CancellationToken ct = default) { - var profile = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct); - if (profile is null) + var document = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct); + if (document is null) return Result.Failure(ProfilesErrors.ProfileNotFound); - profile.UpdateProfile( - dto.DisplayName ?? profile.DisplayName, - dto.About ?? profile.Bio, + document.UpdateProfile( + dto.DisplayName ?? document.DisplayName, + dto.About ?? document.Bio, dto.Birthday); - await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, profile, new ReplaceOptions { IsUpsert = true }, ct); - return Result.Success(profile.ToDto()); + document.UpdateAvatar(dto.Avatar); + + await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct); + return Result.Success(document.ToDto()); } public async Task UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default) diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs index 21568bd..815f191 100644 --- a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs @@ -53,9 +53,11 @@ public static class ProfilesEndpoints int.TryParse(form["y"], out int y); int.TryParse(form["width"], out int width); int.TryParse(form["height"], out int height); + int.TryParse(form["sw"], out int sw); + int.TryParse(form["sh"], out int sh); using var stream = file.OpenReadStream(); - var result = await sender.Send(new CropAvatarCommand(userContext.UserId, stream, file.FileName ?? "avatar.jpg", file.ContentType, x, y, width, height), ct); + var result = await sender.Send(new CropAvatarCommand(userContext.UserId, stream, file.FileName ?? "avatar.jpg", file.ContentType, x, y, width, height, sw, sh), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); }).DisableAntiforgery(); diff --git a/client-web/src/core/utils/cropImage.ts b/client-web/src/core/utils/cropImage.ts new file mode 100644 index 0000000..f36aa44 --- /dev/null +++ b/client-web/src/core/utils/cropImage.ts @@ -0,0 +1,42 @@ +export const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export async function getCroppedImg( + imageSrc: string, + pixelCrop: { x: number, y: number, width: number, height: number } +): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + return new Promise((resolve) => { + canvas.toBlob((blob) => { + resolve(blob); + }, 'image/jpeg', 0.95); + }); +} diff --git a/client-web/src/core/utils/utils.ts b/client-web/src/core/utils/utils.ts index 15019bf..8aa7a93 100644 --- a/client-web/src/core/utils/utils.ts +++ b/client-web/src/core/utils/utils.ts @@ -137,8 +137,7 @@ export function getMediaUrl(url: string | null | undefined): string { if (!url) return ''; if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url; - // Use VITE_API_URL if defined, otherwise let it be a relative path which the browser - // will resolve against the current origin (port). - const baseUrl = import.meta.env.VITE_API_URL || ''; + // Use VITE_API_URL if defined, but avoid duplicating '/api' + const baseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/api$/, ''); return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`; } diff --git a/client-web/src/modules/users/infrastructure/userApi.ts b/client-web/src/modules/users/infrastructure/userApi.ts index 405a7f8..32d7711c 100644 --- a/client-web/src/modules/users/infrastructure/userApi.ts +++ b/client-web/src/modules/users/infrastructure/userApi.ts @@ -39,7 +39,7 @@ export class UserApi { }); } - static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) { + static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number; sw: number; sh: number }) { // Конечная точка в новом бэкенде: POST /api/profiles/avatar/crop const formData = new FormData(); formData.append('avatar', file); @@ -47,6 +47,8 @@ export class UserApi { formData.append('y', cropData.y.toString()); formData.append('width', cropData.width.toString()); formData.append('height', cropData.height.toString()); + formData.append('sw', cropData.sw.toString()); + formData.append('sh', cropData.sh.toString()); return httpClient.request('/profiles/avatar/crop', { method: 'POST', diff --git a/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx b/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx index 119125f..83aaa8c 100644 --- a/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx +++ b/client-web/src/modules/users/presentation/components/AvatarCropModal.tsx @@ -6,7 +6,7 @@ import { useLang } from '../../../../core/infrastructure/i18n'; interface AvatarCropModalProps { image: string; - onCrop: (cropData: Area) => void; + onCrop: (cropData: Area, pixelCropData: Area) => void; onClose: () => void; } @@ -14,15 +14,18 @@ export default function AvatarCropModal({ image, onCrop, onClose }: AvatarCropMo const { t } = useLang(); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); - const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { + + const onCropComplete = useCallback((croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedArea); setCroppedAreaPixels(croppedAreaPixels); }, []); const handleSave = () => { - if (croppedAreaPixels) { - onCrop(croppedAreaPixels); + if (croppedArea && croppedAreaPixels) { + onCrop(croppedArea, croppedAreaPixels); } }; diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx index 866203a..8ee241a 100644 --- a/client-web/src/modules/users/presentation/components/SettingsPage.tsx +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -13,6 +13,7 @@ import { getMediaUrl, getInitials } from '../../../../core/utils/utils'; import DatePicker from '../../../../core/presentation/components/ui/DatePicker'; import AvatarCropModal from './AvatarCropModal'; import { Area } from 'react-easy-crop'; +import { getCroppedImg } from '../../../../core/utils/cropImage'; export default function SettingsPage() { const { user, updateUser, logout } = useAuthStore(); @@ -27,7 +28,11 @@ export default function SettingsPage() { const [saving, setSaving] = useState(false); // Avatar cropping state - const [cropModal, setCropModal] = useState<{ open: boolean, image: string, file: File | null }>({ open: false, image: '', file: null }); + const [cropModal, setCropModal] = useState<{ + open: boolean, + image: string, + file: File | null + }>({ open: false, image: '', file: null }); const [uploadingAvatar, setUploadingAvatar] = useState(false); const fileInputRef = useRef(null); @@ -63,26 +68,30 @@ export default function SettingsPage() { if (file) { const reader = new FileReader(); reader.onload = () => { - setCropModal({ open: true, image: reader.result as string, file }); + setCropModal({ + open: true, + image: reader.result as string, + file + }); }; reader.readAsDataURL(file); } + e.target.value = ''; }; - const onCropConfirm = async (cropData: Area) => { - if (!cropModal.file) return; + const onCropConfirm = async (_area: Area, pixelArea: Area) => { + if (!cropModal.file || !cropModal.image) return; setCropModal(prev => ({ ...prev, open: false })); setUploadingAvatar(true); try { - const updatedUser = await UserApi.cropAvatar(cropModal.file, { - x: Math.round(cropData.x), - y: Math.round(cropData.y), - width: Math.round(cropData.width), - height: Math.round(cropData.height) - }); + const croppedBlob = await getCroppedImg(cropModal.image, pixelArea); + if (!croppedBlob) throw new Error('Failed to crop image'); + + const croppedFile = new File([croppedBlob], 'avatar.jpg', { type: 'image/jpeg' }); + const updatedUser = await UserApi.uploadAvatar(croppedFile); updateUser(updatedUser); } catch (err) { - console.error(err); + console.error('Failed to update avatar:', err); } finally { setUploadingAvatar(false); }