Аватар

This commit is contained in:
Халимов Рустам
2026-04-05 01:27:58 +03:00
parent 53f193970c
commit e11240f78f
13 changed files with 133 additions and 47 deletions

View File

@@ -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

View File

@@ -29,7 +29,8 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
DisplayName = user.DisplayName,
Avatar = user.Avatar
};
return Result.Success(response);

View File

@@ -72,6 +72,9 @@ public sealed class User : AggregateRoot<Guid>
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);

View File

@@ -65,6 +65,7 @@ internal sealed class UserRepository : IUserRepository
{
domainUser.UpdateFromContract(user);
_context.Users.Update(domainUser);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -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<UserProfileDto>;
int X, int Y, int Width, int Height,
int SourceWidth, int SourceHeight) : ICommand<UserProfileDto>;
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, UserProfileDto>
{
@@ -53,11 +49,16 @@ internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarComma
private static async Task<MemoryStream> 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))

View File

@@ -18,11 +18,16 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarC
{
private readonly IProfileRepository _repository;
private readonly IAvatarStorageService _avatarStorage;
private readonly Knot.Contracts.Auth.Domain.IUserRepository _userRepository;
public UploadAvatarCommandHandler(IProfileRepository repository, IAvatarStorageService avatarStorage)
public UploadAvatarCommandHandler(
IProfileRepository repository,
IAvatarStorageService avatarStorage,
Knot.Contracts.Auth.Domain.IUserRepository userRepository)
{
_repository = repository;
_avatarStorage = avatarStorage;
_userRepository = userRepository;
}
public async Task<Result<UserProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
@@ -37,6 +42,14 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarC
var fileId = await _avatarStorage.UploadAsync(request.FileStream, request.FileName, request.ContentType, cancellationToken);
var avatarUrl = $"/api/files/{fileId}";
// Синхронизируем с основным модулем пользователей (Auth/Postgres)
var userContract = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (userContract != null)
{
userContract.Avatar = avatarUrl;
await _userRepository.UpdateAsync(userContract, cancellationToken);
}
profile.Avatar = avatarUrl;
var result = await _repository.UpdateAsync(profile, cancellationToken);
if (result.IsFailure)
@@ -52,11 +65,16 @@ internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarC
{
private readonly IProfileRepository _repository;
private readonly IAvatarStorageService _avatarStorage;
private readonly Knot.Contracts.Auth.Domain.IUserRepository _userRepository;
public DeleteAvatarCommandHandler(IProfileRepository repository, IAvatarStorageService avatarStorage)
public DeleteAvatarCommandHandler(
IProfileRepository repository,
IAvatarStorageService avatarStorage,
Knot.Contracts.Auth.Domain.IUserRepository userRepository)
{
_repository = repository;
_avatarStorage = avatarStorage;
_userRepository = userRepository;
}
public async Task<Result<UserProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
@@ -68,6 +86,14 @@ internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarC
if (!string.IsNullOrEmpty(profile.Avatar))
await _avatarStorage.DeleteAsync(profile.Avatar, cancellationToken);
// Синхронизируем с основным модулем пользователей (Auth/Postgres)
var userContract = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (userContract != null)
{
userContract.Avatar = null;
await _userRepository.UpdateAsync(userContract, cancellationToken);
}
profile.Avatar = null;
var result = await _repository.UpdateAsync(profile, cancellationToken);
if (result.IsFailure)

View File

@@ -1,11 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Infrastructure.Mappings;
using Knot.Shared.Kernel;
using MongoDB.Bson;
@@ -76,17 +70,19 @@ internal class ProfileRepository : IProfileRepository
public async Task<Result<UserProfileDto>> 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<UserProfileDto>(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<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)

View File

@@ -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();

View File

@@ -0,0 +1,42 @@
export const createImage = (url: string): Promise<HTMLImageElement> =>
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<Blob | null> {
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);
});
}

View File

@@ -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}`;
}

View File

@@ -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<User>('/profiles/avatar/crop', {
method: 'POST',

View File

@@ -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<Point>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(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);
}
};

View File

@@ -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<HTMLInputElement>(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);
}