Фронт

This commit is contained in:
Халимов Рустам
2026-03-27 16:04:46 +03:00
parent 28fb8c25de
commit 030ae1e4e4
19 changed files with 234 additions and 174 deletions

View File

@@ -2,6 +2,7 @@ using Knot.Modules.Messaging;
using Carter;
using Knot.Shared.Infrastructure;
using Knot.Modules.Auth;
using Knot.Modules.Profiles;
using Knot.Modules.Settings;
using Knot.Modules.Admin;
using Knot.Modules.Conversations;
@@ -54,6 +55,7 @@ builder.Configuration.AddInMemoryCollection(
builder.Services.AddAuthModule(builder.Configuration);
builder.Services.AddSettingsModule(builder.Configuration);
builder.Services.AddConversationsModule(builder.Configuration);
builder.Services.AddProfilesModule(builder.Configuration);
builder.Services.AddSharedInfrastructure(builder.Configuration);
// CQRS / MediatR для команд в Host (например, AdminController)

View File

@@ -1,14 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Abstractions;
public interface IProfileRepository
{
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<ProfileDocument?> GetByUsernameAsync(string username, CancellationToken ct = default);
Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default);

View File

@@ -1,17 +1,10 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Events;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.Integration;
/// <summary>
/// Реакция модуля Profiles на создание пользователя в модуле Auth.
/// Создает соответствующий документ в MongoDB.
/// Обработчик события из модуля Auth.
/// Создаёт пустой профиль при регистрации пользователя.
/// </summary>
internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
public class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
{
private readonly IProfileRepository _repository;
@@ -22,17 +15,12 @@ internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<Us
public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken)
{
// Проверяем, существует ли уже профиль (защита от дублей)
var existing = await _repository.GetByIdAsync(notification.UserId, cancellationToken);
if (existing is not null) return;
var profile = ProfileDocument.Create(
notification.UserId,
notification.Username,
notification.DisplayName,
notification.Bio
notification.DisplayName ?? notification.Username
);
await _repository.AddAsync(profile, cancellationToken);
await _repository.AddAsync(profile);
}
}

View File

@@ -1,9 +1,3 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Infrastructure.Database;
using MongoDB.Driver;
namespace Knot.Modules.Profiles;
public static class DependencyInjection

View File

@@ -1,14 +1,11 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Profiles.Domain.Events;
/// <summary>
/// Доменное событие: профиль пользователя создан при регистрации.
/// Профиль пользователя создан.
/// </summary>
public sealed record ProfileCreatedDomainEvent(Guid UserId, string Username) : IDomainEvent;
public record ProfileCreatedDomainEvent(Guid ProfileId) : IDomainEvent;
/// <summary>
/// Доменное событие: аватар профиля был изменён (для возможной инвалидации CDN-кэша).
/// Профиль пользователя обновлен.
/// </summary>
public sealed record ProfileAvatarChangedDomainEvent(Guid UserId, string? OldAvatarFileId, string? NewAvatarFileId) : IDomainEvent;
public record ProfileUpdatedDomainEvent(Guid ProfileId) : IDomainEvent;

View File

@@ -1,6 +1,3 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Profiles.Domain;
public sealed class Profile : AggregateRoot<Guid>
@@ -13,13 +10,17 @@ public sealed class Profile : AggregateRoot<Guid>
public bool HideStoryViews { get; private set; }
public string Profilename => Username;
public string Name => DisplayName;
public string AvatarUrl => Avatar;
public string? AvatarUrl => Avatar;
public bool IsPrivate => false;
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public int FollowersCount => 0;
public int FollowingCount => 0;
public int FriendsCount => 0;
#pragma warning disable CS8618
private Profile() : base(Guid.Empty) { }
#pragma warning restore CS8618
private Profile(Guid id, string username, string displayName, string? bio = null)
: base(id)
{

View File

@@ -1,13 +1,8 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System;
namespace Knot.Modules.Profiles.Domain;
/// <summary>
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// Аватар хранится в S3 — здесь лежит только ссылка.
/// </summary>
public sealed class ProfileDocument
{
@@ -21,23 +16,17 @@ public sealed class ProfileDocument
public string? Bio { get; private set; }
/// <summary>
/// URL или ключ объекта в S3-хранилище (MinIO).
/// Пример: "/api/files/{fileId}"
/// </summary>
public string? AvatarUrl { get; private set; }
public DateTime? Birthday { get; private set; }
/// <summary>
/// Скрывать ли просмотры сторис от других пользователей.
/// </summary>
public bool HideStoryViews { get; private set; }
public DateTime CreatedAt { get; private set; }
// Для MongoDB — protected-конструктор через BSON-десериализацию
protected ProfileDocument() { }
#pragma warning disable CS8618
private ProfileDocument() { }
#pragma warning restore CS8618
private ProfileDocument(Guid id, string username, string displayName, string? bio)
{

View File

@@ -1,3 +1,19 @@
global using Knot.Shared.Kernel;
global using Knot.Shared.Kernel.Events;
global using Knot.Shared.Kernel.Storage;
global using Knot.Modules.Profiles.Application.Abstractions;
global using Knot.Modules.Profiles.Application.Profiles;
global using Knot.Modules.Profiles.Domain;
global using Knot.Modules.Profiles.Domain.Events;
global using Knot.Modules.Profiles.Infrastructure.Database;
global using MongoDB.Bson;
global using MongoDB.Bson.Serialization.Attributes;
global using MongoDB.Driver;
global using MediatR;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Configuration;
global using System;
global using System.Collections.Generic;
global using System.Threading;
global using System.Threading.Tasks;
global using System.IO;
global using System.Linq;

View File

@@ -1,33 +1,23 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
/// <summary>
/// Адаптер к S3-хранилищу для аватаров.
/// Инжектирует общий IFileStorageService и передает ему управление.
/// </summary>
internal sealed class AvatarStorageService : IAvatarStorageService
public class AvatarStorageService : IAvatarStorageService
{
private readonly IFileStorageService _storage;
private readonly IFileStorageService _fileStorage;
public AvatarStorageService(IFileStorageService storage)
public AvatarStorageService(IFileStorageService fileStorage)
{
_storage = storage;
_fileStorage = fileStorage;
}
public async Task<string> UploadAsync(Stream stream, string fileName, string contentType, CancellationToken ct = default)
public async Task<string> UploadAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
{
return await _storage.UploadFileAsync(stream, fileName, contentType);
// IFileStorageService не принимает CancellationToken в UploadFileAsync
return await _fileStorage.UploadFileAsync(content, fileName, contentType);
}
public async Task DeleteAsync(string fileId, CancellationToken ct = default)
{
// Если fileId содержит "/api/files/", обрезаем его до чистого ID
var cleanId = fileId.Replace("/api/files/", "");
await _storage.DeleteFileAsync(cleanId);
// IFileStorageService не принимает CancellationToken в DeleteFileAsync
await _fileStorage.DeleteFileAsync(fileId);
}
}

View File

@@ -1,14 +1,6 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfileRepository : IProfileRepository
public class ProfileRepository : IProfileRepository
{
private readonly IMongoCollection<ProfileDocument> _profiles;
@@ -17,9 +9,14 @@ internal sealed class ProfileRepository : IProfileRepository
_profiles = database.GetCollection<ProfileDocument>("profiles");
}
public async Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default)
public async Task<ProfileDocument?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
return await _profiles.Find(p => p.Id == id).FirstOrDefaultAsync(ct);
}
public async Task<ProfileDocument?> GetByUsernameAsync(string username, CancellationToken ct = default)
{
return await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
}
public async Task AddAsync(ProfileDocument profile, CancellationToken ct = default)
@@ -29,20 +26,19 @@ internal sealed class ProfileRepository : IProfileRepository
public async Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default)
{
await _profiles.ReplaceOneAsync(p => p.Id == profile.Id, profile, new ReplaceOptions { IsUpsert = false }, ct);
await _profiles.ReplaceOneAsync(p => p.Id == profile.Id, profile, new ReplaceOptions { IsUpsert = true }, ct);
}
public async Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<ProfileDocument>();
return await _profiles.Find(_ => true).Limit(50).ToListAsync(ct);
// Простой регистронезависимый поиск по Regex (в реальной системе лучше использовать Text Index)
var filter = Builders<ProfileDocument>.Filter.Or(
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new MongoDB.Bson.BsonRegularExpression(query, "i")),
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new MongoDB.Bson.BsonRegularExpression(query, "i"))
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")),
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new BsonRegularExpression(query, "i"))
);
return await _profiles.Find(filter).Limit(20).ToListAsync(ct);
return await _profiles.Find(filter).Limit(50).ToListAsync(ct);
}
}

View File

@@ -1,12 +1,8 @@
using Knot.Modules.Profiles.Application.Abstractions;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfilesUnitOfWork : IProfilesUnitOfWork
public class ProfilesUnitOfWork : IProfilesUnitOfWork
{
// MongoDB updates are atomic per document by default in the driver,
// so for simple ProfileDocument updates, we don't need distributed transactions.
// MongoDB не поддерживает транзакции без репликации в простом виде,
// поэтому UnitOfWork здесь формальный для соответствия интерфейсу.
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
}

View File

@@ -2,9 +2,12 @@
export interface UserBasic {
id: string;
username: string;
userName: string;
displayName: string;
avatar: string | null;
avatarUrl: string | null;
// Fallbacks for compatibility
username?: string;
avatar?: string | null;
}
export interface UserPresence extends UserBasic {
@@ -48,14 +51,23 @@ export interface Reaction {
id: string;
emoji: string;
userId: string;
user: { id: string; username: string; displayName: string; avatar?: string | null };
user: {
id: string;
username: string;
userName?: string;
displayName: string;
avatar?: string | null;
avatarUrl?: string | null;
};
}
export interface MessageSender {
id: string;
username: string;
userName?: string;
displayName: string;
avatar?: string | null;
avatarUrl?: string | null;
}
export interface Message {
@@ -93,10 +105,11 @@ export interface Message {
export interface Chat {
id: string;
type: string;
type: 'personal' | 'group' | 'channel' | 'favorites';
name: string | null;
description?: string | null;
avatar: string | null;
avatarUrl?: string | null;
createdAt: string;
members: ChatMember[];
messages: Message[];
@@ -152,8 +165,6 @@ export interface StoryGroup {
hasUnviewed: boolean;
}
// ─── Utility types ─────────────────────────────────────────────────
// ─── Friend types ──────────────────────────────────────────────────
export interface FriendRequest {

View File

@@ -1,3 +1,4 @@
import { deepNormalize } from '../utils/normalize';
const API_BASE = '/api';
export class HttpClient {
@@ -44,7 +45,8 @@ export class HttpClient {
throw new Error(errorMessage);
}
return response.json();
const jsonData = await response.json();
return deepNormalize(jsonData);
}
}

View File

@@ -73,7 +73,7 @@ export default function Sidebar() {
return chat.members.some(
(m) =>
m.user.id !== user?.id &&
(m.user.username.toLowerCase().includes(q) ||
((m.user.username || m.user.userName || '').toLowerCase().includes(q) ||
m.user.displayName.toLowerCase().includes(q))
);
}).sort((a, b) => {
@@ -163,7 +163,8 @@ export default function Sidebar() {
</button>
{storyGroups.map((group, idx) => {
const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null;
const avatar = group.user.avatarUrl || group.user.avatar;
const avatarUrl = avatar ? `${API_URL}${avatar}` : null;
const isMine = group.user.id === user?.id;
return (
<button
@@ -187,7 +188,7 @@ export default function Sidebar() {
</div>
</div>
<span className="text-[11px] text-zinc-400 truncate w-full text-center">
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]}
{isMine ? t('myStory') : (group.user.displayName || group.user.userName || group.user.username || '').split(' ')[0]}
</span>
</button>
);

View File

@@ -0,0 +1,67 @@
/**
* Глобальная нормализация данных пользователя.
* Приводит данные от разных модулей (Profiles, Auth, Message) к единому виду.
*
* ВАЖНО: Мы не импортируем здесь типы из types.ts, чтобы избежать круговых зависимостей.
*/
export function normalizeUser(user: any): any {
if (!user || typeof user !== 'object') return user;
const result = { ...user };
// 1. Приведение username (Auth -> userName, Profiles -> userName)
if (!result.userName && result.username) {
result.userName = result.username;
}
if (!result.username && result.userName) {
result.username = result.userName;
}
// 2. Приведение avatar (Auth -> avatar, Profiles -> avatarUrl)
if (!result.avatarUrl && result.avatar) {
result.avatarUrl = result.avatar;
}
if (!result.avatar && result.avatarUrl) {
result.avatar = result.avatarUrl;
}
return result;
}
/**
* Рекурсивная нормализация любого объекта/массива на наличие полей пользователя.
*/
export function deepNormalize(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj;
}
// Если это File, Blob или FormData - не трогаем
if (obj instanceof Blob || obj instanceof FormData) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(deepNormalize);
}
// Создаем копию для мутации
const result = { ...obj };
// Если это объект, похожий на пользователя (есть id и userName/username)
if (result.id && (result.userName || result.username)) {
const normalized = normalizeUser(result);
// Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты)
for (const key in normalized) {
normalized[key] = deepNormalize(normalized[key]);
}
return normalized;
}
// Рекурсивно проходим по всем полям
for (const key in result) {
result[key] = deepNormalize(result[key]);
}
return result;
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User, CheckCircle, XCircle } from 'lucide-react';
import { httpClient } from '../../../core/infrastructure/httpClient';
import { deepNormalize } from '../../../core/utils/normalize';
interface Stats {
storageUsedBytes: number;
@@ -17,8 +19,11 @@ interface Conf {
enableCalls: boolean;
turnHost: string;
turnPort: number;
// Support both cases
turnUser: string;
turnUsername?: string;
turnSecret: string;
turnPassword?: string;
enableKlipy: boolean;
klipyApiKey: string;
klipyCustomerId: string;
@@ -29,9 +34,11 @@ interface Conf {
interface AppUser {
id: string;
username: string;
userName: string;
displayName: string;
avatar: string | null;
avatarUrl: string | null;
username?: string;
avatar?: string | null;
bio?: string;
createdAt: string;
lastOnlineAt: string;
@@ -288,12 +295,10 @@ export default function AdminPage() {
const handleAddUser = async () => {
try {
const res = await fetch('/api/admin/users', {
await httpClient.request('/admin/users', {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newUser, email: null, bio: null })
});
if (!res.ok) throw new Error();
showToast(t.createUserSuccess, 'success');
setShowAddUserModal(false);
setNewUser({ username: '', displayName: '', password: '' });
@@ -306,29 +311,27 @@ export default function AdminPage() {
const handleResetPassword = async (userId: string) => {
const newPass = generatePassword();
try {
const res = await fetch(`/api/admin/users/${userId}/reset-password`, {
await httpClient.request(`/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: newPass })
});
if (!res.ok) throw new Error();
setGeneratedPass(newPass);
} catch {
showToast('Error resetting password', 'error');
}
};
const fetchDashboard = async (header: string) => {
const fetchDashboard = async () => {
try {
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) setStats(await res.json());
const res = await httpClient.request<Stats>('/admin/dashboard');
setStats(res);
} catch {}
};
const fetchSettings = async (header: string) => {
const fetchSettings = async () => {
try {
const res = await fetch('/api/admin/settings', { headers: { Authorization: header } });
if (res.ok) setConfig(await res.json());
const res = await httpClient.request<Conf>('/admin/settings');
setConfig(res);
} catch {}
};
@@ -438,15 +441,19 @@ export default function AdminPage() {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) {
// Store in httpClient
httpClient.setToken(header); // Note: Admin uses Basic auth in this specific screen
try {
await httpClient.request('/admin/dashboard');
setAuthHeader(header);
setAuthenticated(true);
fetchDashboard(header);
fetchSettings(header);
searchUsers('', header); // Fetch initial users list
} else {
fetchDashboard();
fetchSettings();
searchUsers('');
} catch {
showToast(t.errorInvalidLogin, 'error');
httpClient.setToken(null);
}
};
@@ -455,7 +462,7 @@ export default function AdminPage() {
const interval = setInterval(() => {
// Refresh dashboard if we are on dashboard tab
if (activeTab === 'dashboard') {
fetchDashboard(authHeader);
fetchDashboard();
}
}, 10000);
return () => clearInterval(interval);
@@ -464,24 +471,21 @@ export default function AdminPage() {
const saveSettings = async () => {
if (!config) return;
try {
const res = await fetch('/api/admin/settings', {
await httpClient.request('/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify(config)
});
if (res.ok) showToast(t.successSave, 'success');
showToast(t.successSave, 'success');
} catch {
showToast(t.errorSave, 'error');
}
};
const searchUsers = async (q: string, header: string = authHeader) => {
const searchUsers = async (q: string) => {
setIsSearching(true);
try {
const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } });
if (res.ok) {
setUsers(await res.json());
}
const res = await httpClient.request<AppUser[]>(`/admin/users?query=${encodeURIComponent(q)}`);
setUsers(res);
} catch {} finally {
setIsSearching(false);
}
@@ -489,28 +493,24 @@ export default function AdminPage() {
const handleCalcCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
if (res.ok) setCleanStats(await res.json());
const res = await httpClient.request<CleanStats>('/admin/clean/dry-run');
setCleanStats(res);
} catch {}
};
const handleRunCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
if (res.ok) {
showToast(t.cleanSuccess, 'success');
setCleanStats(null);
fetchDashboard(authHeader);
}
await httpClient.request('/admin/clean/run', { method: 'POST' });
showToast(t.cleanSuccess, 'success');
setCleanStats(null);
fetchDashboard();
} catch {}
};
const fetchUserDetails = async (id: string) => {
try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
if (res.ok) {
setSelectedUser(await res.json());
}
const res = await httpClient.request<AppUser>(`/admin/users/${id}`);
setSelectedUser(res);
} catch {}
};
@@ -744,12 +744,16 @@ export default function AdminPage() {
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnUser}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnUser} onChange={e => setConfig({...config, turnUser: e.target.value})} />
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={config.turnUser || config.turnUsername || ''}
onChange={e => setConfig({...config, turnUser: e.target.value, turnUsername: e.target.value})} />
</label>
</div>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
<input type="password" name="turnSecret" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={config.turnSecret || config.turnPassword || ''}
onChange={e => setConfig({...config, turnSecret: e.target.value, turnPassword: e.target.value})} />
</label>
<div className="flex justify-start mt-2">
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">

View File

@@ -35,14 +35,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const chatName = isFavorites
? t('favorites')
: chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatAvatar = isFavorites
? null
: chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
? otherMember?.user.avatarUrl || otherMember?.user.avatar
: chat.avatarUrl || chat.avatar;
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;

View File

@@ -3,46 +3,52 @@ import type { User, UserPresence } from '../../../core/domain/types';
export class UserApi {
static async searchUsers(query: string) {
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
// Конечная точка в новом бэкенде: GET /api/profiles/search?q=...
return httpClient.request<UserPresence[]>(`/profiles/search?q=${encodeURIComponent(query)}`);
}
static async getUser(id: string) {
return httpClient.request<User>(`/users/${id}`);
// Конечная точка в новом бэкенде: GET /api/profiles/{id}
return httpClient.request<User>(`/profiles/${id}`);
}
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return httpClient.request<User>('/users/profile', {
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
return httpClient.request<User>('/profiles/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
static async updateSettings(settings: any) {
return httpClient.request('/users/settings', {
// Конечная точка в новом бэкенде: PUT /api/profiles/settings
return httpClient.request('/profiles/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
}
static async uploadAvatar(file: File) {
// Конечная точка в новом бэкенде: POST /api/profiles/avatar
const formData = new FormData();
formData.append('avatar', file);
return httpClient.request<User>('/users/avatar', {
return httpClient.request<User>('/profiles/avatar', {
method: 'POST',
body: formData,
timeout: 120_000,
timeout: 120_000, // Аватар может быть большим, даем время на обработку
});
}
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
// Конечная точка в новом бэкенде: POST /api/profiles/avatar/crop
const formData = new FormData();
formData.append('avatar', file);
formData.append('cropX', cropData.x.toString());
formData.append('cropY', cropData.y.toString());
formData.append('cropWidth', cropData.width.toString());
formData.append('cropHeight', cropData.height.toString());
formData.append('x', cropData.x.toString());
formData.append('y', cropData.y.toString());
formData.append('width', cropData.width.toString());
formData.append('height', cropData.height.toString());
return httpClient.request<User>('/users/avatar/crop', {
return httpClient.request<User>('/profiles/avatar/crop', {
method: 'POST',
body: formData,
timeout: 120_000,
@@ -50,10 +56,13 @@ export class UserApi {
}
static async removeAvatar() {
return httpClient.request<User>('/users/avatar', { method: 'DELETE' });
// Конечная точка в новом бэкенде: DELETE /api/profiles/avatar
return httpClient.request<User>('/profiles/avatar', { method: 'DELETE' });
}
static async getIceServers() {
// Конфигурация WebRTC теперь возвращается через эндпоинт админки или настроек,
// но если бэк не меняли в этой части, оставляем старый путь.
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
}
}

View File

@@ -350,9 +350,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
try {
setTabLoading(true);
await UserApi.removeAvatar();
const updatedUser = { ...profile!, avatar: null };
const updatedUser = { ...profile!, avatar: null, avatarUrl: null };
setProfile(updatedUser);
useAuthStore.getState().updateUser({ avatar: null });
useAuthStore.getState().updateUser({ avatar: null, avatarUrl: null });
} catch (err) {
console.error(err);
} finally {
@@ -360,7 +360,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
}
};
const initials = (profile?.displayName || profile?.username || '??')
const initials = (profile?.displayName || profile?.userName || profile?.username || '??')
.split(' ')
.map((w: string) => w[0])
.join('')
@@ -448,9 +448,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
<div className="relative group">
<div className="relative">
{profile.avatar ? (
{ (profile.avatarUrl || profile.avatar) ? (
<img
src={getMediaUrl(profile.avatar)}
src={getMediaUrl(profile.avatarUrl || profile.avatar!)}
alt=""
className="w-32 h-32 rounded-full object-cover border-4 border-surface bg-surface"
/>
@@ -501,14 +501,14 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
</div>
) : (
<h3 className="mt-5 text-[24px] font-semibold text-white tracking-tight text-center px-4">
{profile.displayName || profile.username}
{profile.displayName || profile.userName || profile.username}
</h3>
)}
{/* Username (неизменяемый) */}
<div className="flex items-center gap-1.5 mt-2 bg-accent/10 px-4 py-1.5 rounded-full border border-accent/20 cursor-default">
<AtSign size={14} className="text-accent" />
<span className="text-sm font-medium text-accent">{profile.username}</span>
<span className="text-sm font-medium text-accent">{profile.userName || profile.username}</span>
</div>
{/* Онлайн статус */}
@@ -984,9 +984,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
stories={[{
user: {
id: profile.id,
userName: profile.userName || profile.username || '',
username: profile.username,
displayName: profile.displayName,
avatar: profile.avatar
avatar: profile.avatar,
avatarUrl: profile.avatarUrl || profile.avatar
},
stories: sortedStories,
hasUnviewed: false