Фронт
This commit is contained in:
@@ -2,6 +2,7 @@ using Knot.Modules.Messaging;
|
|||||||
using Carter;
|
using Carter;
|
||||||
using Knot.Shared.Infrastructure;
|
using Knot.Shared.Infrastructure;
|
||||||
using Knot.Modules.Auth;
|
using Knot.Modules.Auth;
|
||||||
|
using Knot.Modules.Profiles;
|
||||||
using Knot.Modules.Settings;
|
using Knot.Modules.Settings;
|
||||||
using Knot.Modules.Admin;
|
using Knot.Modules.Admin;
|
||||||
using Knot.Modules.Conversations;
|
using Knot.Modules.Conversations;
|
||||||
@@ -54,6 +55,7 @@ builder.Configuration.AddInMemoryCollection(
|
|||||||
builder.Services.AddAuthModule(builder.Configuration);
|
builder.Services.AddAuthModule(builder.Configuration);
|
||||||
builder.Services.AddSettingsModule(builder.Configuration);
|
builder.Services.AddSettingsModule(builder.Configuration);
|
||||||
builder.Services.AddConversationsModule(builder.Configuration);
|
builder.Services.AddConversationsModule(builder.Configuration);
|
||||||
|
builder.Services.AddProfilesModule(builder.Configuration);
|
||||||
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// CQRS / MediatR для команд в Host (например, AdminController)
|
// CQRS / MediatR для команд в Host (например, AdminController)
|
||||||
|
|||||||
@@ -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;
|
namespace Knot.Modules.Profiles.Application.Abstractions;
|
||||||
|
|
||||||
public interface IProfileRepository
|
public interface IProfileRepository
|
||||||
{
|
{
|
||||||
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
Task<ProfileDocument?> GetByUsernameAsync(string username, CancellationToken ct = default);
|
||||||
Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
|
Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
|
||||||
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
|
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
|
||||||
Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default);
|
Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default);
|
||||||
|
|||||||
+5
-17
@@ -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;
|
namespace Knot.Modules.Profiles.Application.Profiles.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Реакция модуля Profiles на создание пользователя в модуле Auth.
|
/// Обработчик события из модуля Auth.
|
||||||
/// Создает соответствующий документ в MongoDB.
|
/// Создаёт пустой профиль при регистрации пользователя.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
|
public class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
|
||||||
{
|
{
|
||||||
private readonly IProfileRepository _repository;
|
private readonly IProfileRepository _repository;
|
||||||
|
|
||||||
@@ -22,17 +15,12 @@ internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<Us
|
|||||||
|
|
||||||
public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken)
|
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(
|
var profile = ProfileDocument.Create(
|
||||||
notification.UserId,
|
notification.UserId,
|
||||||
notification.Username,
|
notification.Username,
|
||||||
notification.DisplayName,
|
notification.DisplayName ?? notification.Username
|
||||||
notification.Bio
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await _repository.AddAsync(profile, cancellationToken);
|
await _repository.AddAsync(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace Knot.Modules.Profiles;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Domain.Events;
|
namespace Knot.Modules.Profiles.Domain.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Доменное событие: профиль пользователя создан при регистрации.
|
/// Профиль пользователя создан.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ProfileCreatedDomainEvent(Guid UserId, string Username) : IDomainEvent;
|
public record ProfileCreatedDomainEvent(Guid ProfileId) : IDomainEvent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Доменное событие: аватар профиля был изменён (для возможной инвалидации CDN-кэша).
|
/// Профиль пользователя обновлен.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ProfileAvatarChangedDomainEvent(Guid UserId, string? OldAvatarFileId, string? NewAvatarFileId) : IDomainEvent;
|
public record ProfileUpdatedDomainEvent(Guid ProfileId) : IDomainEvent;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Domain;
|
namespace Knot.Modules.Profiles.Domain;
|
||||||
|
|
||||||
public sealed class Profile : AggregateRoot<Guid>
|
public sealed class Profile : AggregateRoot<Guid>
|
||||||
@@ -13,13 +10,17 @@ public sealed class Profile : AggregateRoot<Guid>
|
|||||||
public bool HideStoryViews { get; private set; }
|
public bool HideStoryViews { get; private set; }
|
||||||
public string Profilename => Username;
|
public string Profilename => Username;
|
||||||
public string Name => DisplayName;
|
public string Name => DisplayName;
|
||||||
public string AvatarUrl => Avatar;
|
public string? AvatarUrl => Avatar;
|
||||||
public bool IsPrivate => false;
|
public bool IsPrivate => false;
|
||||||
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
|
||||||
public int FollowersCount => 0;
|
public int FollowersCount => 0;
|
||||||
public int FollowingCount => 0;
|
public int FollowingCount => 0;
|
||||||
public int FriendsCount => 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)
|
private Profile(Guid id, string username, string displayName, string? bio = null)
|
||||||
: base(id)
|
: base(id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
using MongoDB.Bson;
|
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Domain;
|
namespace Knot.Modules.Profiles.Domain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MongoDB-документ профиля пользователя.
|
/// MongoDB-документ профиля пользователя.
|
||||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||||
/// Аватар хранится в S3 — здесь лежит только ссылка.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProfileDocument
|
public sealed class ProfileDocument
|
||||||
{
|
{
|
||||||
@@ -21,23 +16,17 @@ public sealed class ProfileDocument
|
|||||||
|
|
||||||
public string? Bio { get; private set; }
|
public string? Bio { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// URL или ключ объекта в S3-хранилище (MinIO).
|
|
||||||
/// Пример: "/api/files/{fileId}"
|
|
||||||
/// </summary>
|
|
||||||
public string? AvatarUrl { get; private set; }
|
public string? AvatarUrl { get; private set; }
|
||||||
|
|
||||||
public DateTime? Birthday { get; private set; }
|
public DateTime? Birthday { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Скрывать ли просмотры сторис от других пользователей.
|
|
||||||
/// </summary>
|
|
||||||
public bool HideStoryViews { get; private set; }
|
public bool HideStoryViews { get; private set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
// Для MongoDB — protected-конструктор через BSON-десериализацию
|
#pragma warning disable CS8618
|
||||||
protected ProfileDocument() { }
|
private ProfileDocument() { }
|
||||||
|
#pragma warning restore CS8618
|
||||||
|
|
||||||
private ProfileDocument(Guid id, string username, string displayName, string? bio)
|
private ProfileDocument(Guid id, string username, string displayName, string? bio)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.Abstractions;
|
||||||
global using Knot.Modules.Profiles.Application.Profiles;
|
|
||||||
global using Knot.Modules.Profiles.Domain;
|
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;
|
||||||
|
|||||||
@@ -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;
|
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||||
|
|
||||||
/// <summary>
|
public class AvatarStorageService : IAvatarStorageService
|
||||||
/// Адаптер к S3-хранилищу для аватаров.
|
|
||||||
/// Инжектирует общий IFileStorageService и передает ему управление.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed 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)
|
public async Task DeleteAsync(string fileId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Если fileId содержит "/api/files/", обрезаем его до чистого ID
|
// IFileStorageService не принимает CancellationToken в DeleteFileAsync
|
||||||
var cleanId = fileId.Replace("/api/files/", "");
|
await _fileStorage.DeleteFileAsync(fileId);
|
||||||
await _storage.DeleteFileAsync(cleanId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||||
|
|
||||||
internal sealed class ProfileRepository : IProfileRepository
|
public class ProfileRepository : IProfileRepository
|
||||||
{
|
{
|
||||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||||
|
|
||||||
@@ -17,9 +9,14 @@ internal sealed class ProfileRepository : IProfileRepository
|
|||||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
_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)
|
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)
|
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)
|
public async Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
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(
|
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.Username, new BsonRegularExpression(query, "i")),
|
||||||
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new MongoDB.Bson.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
using Knot.Modules.Profiles.Application.Abstractions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
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,
|
// MongoDB не поддерживает транзакции без репликации в простом виде,
|
||||||
// so for simple ProfileDocument updates, we don't need distributed transactions.
|
// поэтому UnitOfWork здесь формальный для соответствия интерфейсу.
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
export interface UserBasic {
|
export interface UserBasic {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
userName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string | null;
|
avatarUrl: string | null;
|
||||||
|
// Fallbacks for compatibility
|
||||||
|
username?: string;
|
||||||
|
avatar?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPresence extends UserBasic {
|
export interface UserPresence extends UserBasic {
|
||||||
@@ -48,14 +51,23 @@ export interface Reaction {
|
|||||||
id: string;
|
id: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
userId: 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 {
|
export interface MessageSender {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
userName?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -93,10 +105,11 @@ export interface Message {
|
|||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: 'personal' | 'group' | 'channel' | 'favorites';
|
||||||
name: string | null;
|
name: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
members: ChatMember[];
|
members: ChatMember[];
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -152,8 +165,6 @@ export interface StoryGroup {
|
|||||||
hasUnviewed: boolean;
|
hasUnviewed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Utility types ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ─── Friend types ──────────────────────────────────────────────────
|
// ─── Friend types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { deepNormalize } from '../utils/normalize';
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
export class HttpClient {
|
export class HttpClient {
|
||||||
@@ -44,7 +45,8 @@ export class HttpClient {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const jsonData = await response.json();
|
||||||
|
return deepNormalize(jsonData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function Sidebar() {
|
|||||||
return chat.members.some(
|
return chat.members.some(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.user.id !== user?.id &&
|
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))
|
m.user.displayName.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
@@ -163,7 +163,8 @@ export default function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{storyGroups.map((group, idx) => {
|
{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;
|
const isMine = group.user.id === user?.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -187,7 +188,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-zinc-400 truncate w-full text-center">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { 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 {
|
interface Stats {
|
||||||
storageUsedBytes: number;
|
storageUsedBytes: number;
|
||||||
@@ -17,8 +19,11 @@ interface Conf {
|
|||||||
enableCalls: boolean;
|
enableCalls: boolean;
|
||||||
turnHost: string;
|
turnHost: string;
|
||||||
turnPort: number;
|
turnPort: number;
|
||||||
|
// Support both cases
|
||||||
turnUser: string;
|
turnUser: string;
|
||||||
|
turnUsername?: string;
|
||||||
turnSecret: string;
|
turnSecret: string;
|
||||||
|
turnPassword?: string;
|
||||||
enableKlipy: boolean;
|
enableKlipy: boolean;
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
klipyCustomerId: string;
|
klipyCustomerId: string;
|
||||||
@@ -29,9 +34,11 @@ interface Conf {
|
|||||||
|
|
||||||
interface AppUser {
|
interface AppUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
userName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string | null;
|
avatarUrl: string | null;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string | null;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastOnlineAt: string;
|
lastOnlineAt: string;
|
||||||
@@ -288,12 +295,10 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleAddUser = async () => {
|
const handleAddUser = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/users', {
|
await httpClient.request('/admin/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...newUser, email: null, bio: null })
|
body: JSON.stringify({ ...newUser, email: null, bio: null })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
showToast(t.createUserSuccess, 'success');
|
showToast(t.createUserSuccess, 'success');
|
||||||
setShowAddUserModal(false);
|
setShowAddUserModal(false);
|
||||||
setNewUser({ username: '', displayName: '', password: '' });
|
setNewUser({ username: '', displayName: '', password: '' });
|
||||||
@@ -306,29 +311,27 @@ export default function AdminPage() {
|
|||||||
const handleResetPassword = async (userId: string) => {
|
const handleResetPassword = async (userId: string) => {
|
||||||
const newPass = generatePassword();
|
const newPass = generatePassword();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/users/${userId}/reset-password`, {
|
await httpClient.request(`/admin/users/${userId}/reset-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ newPassword: newPass })
|
body: JSON.stringify({ newPassword: newPass })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
setGeneratedPass(newPass);
|
setGeneratedPass(newPass);
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Error resetting password', 'error');
|
showToast('Error resetting password', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDashboard = async (header: string) => {
|
const fetchDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
|
const res = await httpClient.request<Stats>('/admin/dashboard');
|
||||||
if (res.ok) setStats(await res.json());
|
setStats(res);
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSettings = async (header: string) => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/settings', { headers: { Authorization: header } });
|
const res = await httpClient.request<Conf>('/admin/settings');
|
||||||
if (res.ok) setConfig(await res.json());
|
setConfig(res);
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -438,15 +441,19 @@ export default function AdminPage() {
|
|||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
|
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
|
||||||
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
|
// Store in httpClient
|
||||||
if (res.ok) {
|
httpClient.setToken(header); // Note: Admin uses Basic auth in this specific screen
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpClient.request('/admin/dashboard');
|
||||||
setAuthHeader(header);
|
setAuthHeader(header);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
fetchDashboard(header);
|
fetchDashboard();
|
||||||
fetchSettings(header);
|
fetchSettings();
|
||||||
searchUsers('', header); // Fetch initial users list
|
searchUsers('');
|
||||||
} else {
|
} catch {
|
||||||
showToast(t.errorInvalidLogin, 'error');
|
showToast(t.errorInvalidLogin, 'error');
|
||||||
|
httpClient.setToken(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -455,7 +462,7 @@ export default function AdminPage() {
|
|||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
// Refresh dashboard if we are on dashboard tab
|
// Refresh dashboard if we are on dashboard tab
|
||||||
if (activeTab === 'dashboard') {
|
if (activeTab === 'dashboard') {
|
||||||
fetchDashboard(authHeader);
|
fetchDashboard();
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -464,24 +471,21 @@ export default function AdminPage() {
|
|||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/settings', {
|
await httpClient.request('/admin/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
});
|
});
|
||||||
if (res.ok) showToast(t.successSave, 'success');
|
showToast(t.successSave, 'success');
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t.errorSave, 'error');
|
showToast(t.errorSave, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchUsers = async (q: string, header: string = authHeader) => {
|
const searchUsers = async (q: string) => {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } });
|
const res = await httpClient.request<AppUser[]>(`/admin/users?query=${encodeURIComponent(q)}`);
|
||||||
if (res.ok) {
|
setUsers(res);
|
||||||
setUsers(await res.json());
|
|
||||||
}
|
|
||||||
} catch {} finally {
|
} catch {} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
@@ -489,28 +493,24 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleCalcCleanup = async () => {
|
const handleCalcCleanup = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
|
const res = await httpClient.request<CleanStats>('/admin/clean/dry-run');
|
||||||
if (res.ok) setCleanStats(await res.json());
|
setCleanStats(res);
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRunCleanup = async () => {
|
const handleRunCleanup = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
|
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
||||||
if (res.ok) {
|
showToast(t.cleanSuccess, 'success');
|
||||||
showToast(t.cleanSuccess, 'success');
|
setCleanStats(null);
|
||||||
setCleanStats(null);
|
fetchDashboard();
|
||||||
fetchDashboard(authHeader);
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserDetails = async (id: string) => {
|
const fetchUserDetails = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
|
const res = await httpClient.request<AppUser>(`/admin/users/${id}`);
|
||||||
if (res.ok) {
|
setSelectedUser(res);
|
||||||
setSelectedUser(await res.json());
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -744,12 +744,16 @@ export default function AdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||||
{t.turnUser}
|
{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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||||
{t.turnSecret}
|
{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>
|
</label>
|
||||||
<div className="flex justify-start mt-2">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const chatName = isFavorites
|
const chatName = isFavorites
|
||||||
? t('favorites')
|
? t('favorites')
|
||||||
: chat.type === 'personal'
|
: 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');
|
: chat.name || t('group');
|
||||||
|
|
||||||
const chatAvatar = isFavorites
|
const chatAvatar = isFavorites
|
||||||
? null
|
? null
|
||||||
: chat.type === 'personal'
|
: chat.type === 'personal'
|
||||||
? otherMember?.user.avatar
|
? otherMember?.user.avatarUrl || otherMember?.user.avatar
|
||||||
: chat.avatar;
|
: chat.avatarUrl || chat.avatar;
|
||||||
|
|
||||||
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
|
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
|
||||||
|
|
||||||
|
|||||||
@@ -3,46 +3,52 @@ import type { User, UserPresence } from '../../../core/domain/types';
|
|||||||
|
|
||||||
export class UserApi {
|
export class UserApi {
|
||||||
static async searchUsers(query: string) {
|
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) {
|
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 }) {
|
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',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateSettings(settings: any) {
|
static async updateSettings(settings: any) {
|
||||||
return httpClient.request('/users/settings', {
|
// Конечная точка в новом бэкенде: PUT /api/profiles/settings
|
||||||
|
return httpClient.request('/profiles/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async uploadAvatar(file: File) {
|
static async uploadAvatar(file: File) {
|
||||||
|
// Конечная точка в новом бэкенде: POST /api/profiles/avatar
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('avatar', file);
|
formData.append('avatar', file);
|
||||||
return httpClient.request<User>('/users/avatar', {
|
return httpClient.request<User>('/profiles/avatar', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
timeout: 120_000,
|
timeout: 120_000, // Аватар может быть большим, даем время на обработку
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
||||||
|
// Конечная точка в новом бэкенде: POST /api/profiles/avatar/crop
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('avatar', file);
|
formData.append('avatar', file);
|
||||||
formData.append('cropX', cropData.x.toString());
|
formData.append('x', cropData.x.toString());
|
||||||
formData.append('cropY', cropData.y.toString());
|
formData.append('y', cropData.y.toString());
|
||||||
formData.append('cropWidth', cropData.width.toString());
|
formData.append('width', cropData.width.toString());
|
||||||
formData.append('cropHeight', cropData.height.toString());
|
formData.append('height', cropData.height.toString());
|
||||||
|
|
||||||
return httpClient.request<User>('/users/avatar/crop', {
|
return httpClient.request<User>('/profiles/avatar/crop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
@@ -50,10 +56,13 @@ export class UserApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async removeAvatar() {
|
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() {
|
static async getIceServers() {
|
||||||
|
// Конфигурация WebRTC теперь возвращается через эндпоинт админки или настроек,
|
||||||
|
// но если бэк не меняли в этой части, оставляем старый путь.
|
||||||
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
|
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,9 +350,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
try {
|
try {
|
||||||
setTabLoading(true);
|
setTabLoading(true);
|
||||||
await UserApi.removeAvatar();
|
await UserApi.removeAvatar();
|
||||||
const updatedUser = { ...profile!, avatar: null };
|
const updatedUser = { ...profile!, avatar: null, avatarUrl: null };
|
||||||
setProfile(updatedUser);
|
setProfile(updatedUser);
|
||||||
useAuthStore.getState().updateUser({ avatar: null });
|
useAuthStore.getState().updateUser({ avatar: null, avatarUrl: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} 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(' ')
|
.split(' ')
|
||||||
.map((w: string) => w[0])
|
.map((w: string) => w[0])
|
||||||
.join('')
|
.join('')
|
||||||
@@ -448,9 +448,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{profile.avatar ? (
|
{ (profile.avatarUrl || profile.avatar) ? (
|
||||||
<img
|
<img
|
||||||
src={getMediaUrl(profile.avatar)}
|
src={getMediaUrl(profile.avatarUrl || profile.avatar!)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-32 h-32 rounded-full object-cover border-4 border-surface bg-surface"
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<h3 className="mt-5 text-[24px] font-semibold text-white tracking-tight text-center px-4">
|
<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>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Username (неизменяемый) */}
|
{/* 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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Онлайн статус */}
|
{/* Онлайн статус */}
|
||||||
@@ -984,9 +984,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
stories={[{
|
stories={[{
|
||||||
user: {
|
user: {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
|
userName: profile.userName || profile.username || '',
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
avatar: profile.avatar
|
avatar: profile.avatar,
|
||||||
|
avatarUrl: profile.avatarUrl || profile.avatar
|
||||||
},
|
},
|
||||||
stories: sortedStories,
|
stories: sortedStories,
|
||||||
hasUnviewed: false
|
hasUnviewed: false
|
||||||
|
|||||||
Reference in New Issue
Block a user