Фронт
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
67
client-web/src/core/utils/normalize.ts
Normal file
67
client-web/src/core/utils/normalize.ts
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user