Аватар
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -65,6 +65,7 @@ internal sealed class UserRepository : IUserRepository
|
||||
{
|
||||
domainUser.UpdateFromContract(user);
|
||||
_context.Users.Update(domainUser);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
42
client-web/src/core/utils/cropImage.ts
Normal file
42
client-web/src/core/utils/cropImage.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user