From 9df7d7aaf14aebc9c6b3a4675f4940a8af180878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Thu, 2 Apr 2026 02:37:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8,=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C,=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetSharedMedia/GetSharedMediaQuery.cs | 2 +- .../Klipy/Commands/MarkGifSharedCommand.cs | 71 + .../Klipy/Queries/GetGifCategoriesQuery.cs | 72 + .../Klipy/Queries/GetRecentGifsQuery.cs | 70 + .../Klipy/Queries/GetTrendingGifsQuery.cs | 8 +- .../Klipy/Queries/SearchGifsQuery.cs | 8 +- .../Presentation/Endpoints/KlipyEndpoints.cs | 26 +- .../Knot.Shared.Kernel/Constants/Klipy.cs | 19 +- client-web/src/core/infrastructure/appApi.ts | 22 +- .../presentation/components/ui/Avatar.tsx | 2 +- .../components/ui/ImageLightbox.tsx | 2 +- .../core/presentation/layouts/SideMenu.tsx | 19 +- client-web/src/core/utils/utils.ts | 16 +- client-web/src/index.css | 480 +++--- .../presentation/components/CallModal.tsx | 6 +- .../modules/chats/presentation/ChatPage.tsx | 2 +- .../presentation/components/ChatView.tsx | 38 +- .../presentation/components/EmojiPicker.tsx | 437 ++++-- .../presentation/components/GroupSettings.tsx | 6 +- .../presentation/components/MessageBubble.tsx | 8 +- .../presentation/components/MessageInput.tsx | 4 +- .../presentation/components/NewChatModal.tsx | 214 +-- .../friends/application/friendStore.ts | 4 +- .../presentation/components/UserProfile.tsx | 1356 ++++------------- 24 files changed, 1365 insertions(+), 1527 deletions(-) create mode 100644 backend/src/Modules/Klipy/Application/Klipy/Commands/MarkGifSharedCommand.cs create mode 100644 backend/src/Modules/Klipy/Application/Klipy/Queries/GetGifCategoriesQuery.cs create mode 100644 backend/src/Modules/Klipy/Application/Klipy/Queries/GetRecentGifsQuery.cs diff --git a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs index 1d44f40..c242f62 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs @@ -95,7 +95,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler +{ + private readonly IKlipySettings _settings; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserContext _userContext; + + public MarkGifSharedCommandHandler(IKlipySettings settings, IHttpClientFactory httpClientFactory, IUserContext userContext) + { + _settings = settings; + _httpClientFactory = httpClientFactory; + _userContext = userContext; + } + + public async Task Handle(MarkGifSharedCommand request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey)) + return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); + + var customerId = _userContext.IsAuthenticated ? _userContext.UserId.ToString().ToLowerInvariant() : Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId; + + var client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + + // Correct URL according to docs: POST https://api.klipy.com/api/v1/{app_key}/gifs/share/{gif_id} + var baseUrl = "https://api.klipy.com/api/v1"; + var url = $"{baseUrl}/{conf.ApiKey}/gifs/share/{request.GifId}"; + + // According to docs, q and customer_id must be in the JSON body + var body = new + { + customer_id = customerId, + q = request.Query ?? "" // Must be empty string if not from search + }; + + using var content = JsonContent.Create(body); + var response = await client.PostAsync(url, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var urlCo = url.Replace("api.klipy.com", "api.klipy.co"); + using var contentCo = JsonContent.Create(body); + response = await client.PostAsync(urlCo, contentCo, cancellationToken); + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + return Result.Failure(new Error(Errors.KlipyApiError, $"Klipy Share API Error: {response.StatusCode} {errorBody}")); + } + } + + return Result.Success(); + } +} diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetGifCategoriesQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetGifCategoriesQuery.cs new file mode 100644 index 0000000..618ecdc --- /dev/null +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetGifCategoriesQuery.cs @@ -0,0 +1,72 @@ +using Knot.Shared.Kernel; +using Knot.Contracts.Settings.Application.Abstractions; +using Knot.Shared.Kernel.Constants; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Knot.Modules.Klipy.Application.Klipy.Queries; + +public record GetGifCategoriesQuery(string CountryCode = "RU") : IQuery; + +internal sealed class GetGifCategoriesQueryHandler : IQueryHandler +{ + private readonly IKlipySettings _settings; + private readonly IMemoryCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserContext _userContext; + + public GetGifCategoriesQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory, IUserContext userContext) + { + _settings = settings; + _cache = cache; + _httpClientFactory = httpClientFactory; + _userContext = userContext; + } + + public async Task> Handle(GetGifCategoriesQuery request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey)) + return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); + + var locale = string.IsNullOrEmpty(request.CountryCode) ? "ru_RU" : (request.CountryCode.Length == 2 ? $"{request.CountryCode.ToLowerInvariant()}_{request.CountryCode.ToUpperInvariant()}" : request.CountryCode); + var customerId = _userContext.IsAuthenticated ? _userContext.UserId.ToString().ToLowerInvariant() : Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId; + + var cacheKeyCat = $"klipy_categories_{conf.ApiKey}_{locale}"; + if (_cache.TryGetValue(cacheKeyCat, out JsonElement cachedResult)) + return Result.Success(cachedResult); + + var client = _httpClientFactory.CreateClient(); + + // Correct URL according to docs: GET https://api.klipy.com/api/v1/{app_key}/gifs/categories?locale={locale}&customer_id={customer_id} + var baseUrl = "https://api.klipy.com/api/v1"; + var url = $"{baseUrl}/{conf.ApiKey}/gifs/categories?locale={locale}&customer_id={customerId}"; + + var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var urlCo = url.Replace("api.klipy.com", "api.klipy.co"); + response = await client.GetAsync(urlCo, cancellationToken); + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + return Result.Failure(new Error(Errors.KlipyApiError, $"Klipy Categories API Error: {response.StatusCode} {errorBody}")); + } + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + _cache.Set(cacheKeyCat, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.CategoriesCacheMinutes)); + + return Result.Success(result); + } +} diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetRecentGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetRecentGifsQuery.cs new file mode 100644 index 0000000..2ef5aee --- /dev/null +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetRecentGifsQuery.cs @@ -0,0 +1,70 @@ +using Knot.Shared.Kernel; +using Knot.Contracts.Settings.Application.Abstractions; +using Knot.Shared.Kernel.Constants; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Knot.Modules.Klipy.Application.Klipy.Queries; + +public record GetRecentGifsQuery(int Page = 1) : IQuery; + +internal sealed class GetRecentGifsQueryHandler : IQueryHandler +{ + private readonly IKlipySettings _settings; + private readonly IMemoryCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IUserContext _userContext; + + public GetRecentGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory, IUserContext userContext) + { + _settings = settings; + _cache = cache; + _httpClientFactory = httpClientFactory; + _userContext = userContext; + } + + public async Task> Handle(GetRecentGifsQuery request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey)) + return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); + + if (!_userContext.IsAuthenticated) + return Result.Failure(new Error("AuthError", "User not authenticated")); + + var customerId = _userContext.UserId.ToString().ToLowerInvariant(); + + // Disable cache for history to ensure it updates immediately + var client = _httpClientFactory.CreateClient(); + + // Correct URL according to docs: GET https://api.klipy.com/api/v1/{app_key}/gifs/recent/{customer_id}?page={page}&per_page={per_page} + var baseUrl = "https://api.klipy.com/api/v1"; + var url = $"{baseUrl}/{conf.ApiKey}/gifs/recent/{customerId}?page={request.Page}&per_page=24"; + + var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var urlCo = url.Replace("api.klipy.com", "api.klipy.co"); + response = await client.GetAsync(urlCo, cancellationToken); + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + return Result.Failure(new Error(Errors.KlipyApiError, $"Klipy Recent API Error: {response.StatusCode} {errorBody}")); + } + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + return Result.Success(result); + } +} diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs index 792c520..1300670 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs @@ -13,7 +13,7 @@ using MediatR; namespace Knot.Modules.Klipy.Application.Klipy.Queries; -public record GetTrendingGifsQuery : IQuery; +public record GetTrendingGifsQuery(int Page = 1) : IQuery; internal sealed class GetTrendingGifsQueryHandler : IQueryHandler { @@ -34,21 +34,21 @@ internal sealed class GetTrendingGifsQueryHandler : IQueryHandler(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); - var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}"; + var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}_{request.Page}"; if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult)) return Result.Success(cachedResult); var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId; var client = _httpClientFactory.CreateClient(); - var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId); + var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, request.Page, 30, "", customerId); var response = await client.GetAsync(urlCo, cancellationToken); if (!response.IsSuccessStatusCode) { if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { - var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId); + var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, request.Page, 30, "", customerId); response = await client.GetAsync(urlCom, cancellationToken); } diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs index f244f2e..005f5d6 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs @@ -13,7 +13,7 @@ using MediatR; namespace Knot.Modules.Klipy.Application.Klipy.Queries; -public record SearchGifsQuery(string Query) : IQuery; +public record SearchGifsQuery(string Query, int Page = 1) : IQuery; internal sealed class SearchGifsQueryHandler : IQueryHandler { @@ -37,7 +37,7 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler(new Error(Errors.InvalidQuery, "Invalid query parameter")); - var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}"; + var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}_{request.Page}"; if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult)) return Result.Success(cachedResult); @@ -45,14 +45,14 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler + group.MapGet("/trending", async ([FromQuery] int page, ISender sender, CancellationToken ct) => { - var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(), ct); + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(page > 0 ? page : 1), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); - group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) => + group.MapGet("/search", async ([FromQuery] string q, [FromQuery] int page, ISender sender, CancellationToken ct) => { - var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q), ct); + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q, page > 0 ? page : 1), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); + + group.MapGet("/recent", async ([FromQuery] int page, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetRecentGifsQuery(page > 0 ? page : 1), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); + }); + + group.MapGet("/categories", async ([FromQuery] string? countryCode, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetGifCategoriesQuery(countryCode ?? "RU"), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); + }); + + group.MapPost("/{gifId}/share", async (string gifId, [FromQuery] string? q, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Commands.MarkGifSharedCommand(gifId, q ?? ""), ct); + return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); + }); } } diff --git a/backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs b/backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs index 3bfcbce..8051e72 100644 --- a/backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs +++ b/backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs @@ -2,14 +2,27 @@ namespace Knot.Shared.Kernel.Constants; public static class Klipy { - public const string ApiUrlCo = "https://api.klipy.co/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}"; - public const string ApiUrlCom = "https://api.klipy.com/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}"; - public const string ResourceTrending = "gifs/trending"; public const string ResourceSearch = "gifs/search"; + public const string ResourceRecent = "gifs/recent"; + public const string ResourceCategories = "gifs/categories"; + public const string ResourceShare = "gifs/{0}/share"; + + public const string ApiUrlCo = "https://api.klipy.co/api/v1/{0}/{1}?page={2}&per_page={3}&{4}&customer_id={5}"; + public const string ApiUrlCom = "https://api.klipy.com/api/v1/{0}/{1}?page={2}&per_page={3}&{4}&customer_id={5}"; + + // Categories use country_code + public const string ApiUrlCategoriesCo = "https://api.klipy.co/api/v1/{0}/{1}?country_code={2}&customer_id={3}"; + public const string ApiUrlCategoriesCom = "https://api.klipy.com/api/v1/{0}/{1}?country_code={2}&customer_id={3}"; + + // Share is POST + public const string ApiUrlShareCo = "https://api.klipy.co/api/v1/{0}/gifs/{1}/share?q={2}&customer_id={3}"; + public const string ApiUrlShareCom = "https://api.klipy.com/api/v1/{0}/gifs/{1}/share?q={2}&customer_id={3}"; public const int TrendingCacheMinutes = 60; public const int SearchCacheMinutes = 15; + public const int CategoriesCacheMinutes = 1440; // 24 hours + public const int RecentCacheMinutes = 5; // Frequent changes public const string DefaultCustomerId = "anonymous"; } diff --git a/client-web/src/core/infrastructure/appApi.ts b/client-web/src/core/infrastructure/appApi.ts index d88b661..ae6e187 100644 --- a/client-web/src/core/infrastructure/appApi.ts +++ b/client-web/src/core/infrastructure/appApi.ts @@ -17,11 +17,25 @@ export class AppApi { }); } - static async getTrendingGifs() { - return httpClient.request('/klipy/trending'); + static async getTrendingGifs(page: number = 1) { + return httpClient.request(`/klipy/trending?page=${page}`); } - static async searchKlipyGifs(query: string) { - return httpClient.request(`/klipy/search?q=${encodeURIComponent(query)}`); + static async searchKlipyGifs(query: string, page: number = 1) { + return httpClient.request(`/klipy/search?q=${encodeURIComponent(query)}&page=${page}`); + } + + static async getRecentGifs(page: number = 1) { + return httpClient.request(`/klipy/recent?page=${page}`); + } + + static async getGifCategories(countryCode: string = 'RU') { + return httpClient.request(`/klipy/categories?countryCode=${countryCode}`); + } + + static async markGifShared(gifId: string, query: string = '') { + return httpClient.request(`/klipy/${gifId}/share?q=${encodeURIComponent(query)}`, { + method: 'POST' + }); } } diff --git a/client-web/src/core/presentation/components/ui/Avatar.tsx b/client-web/src/core/presentation/components/ui/Avatar.tsx index 164a6f2..a3a904b 100644 --- a/client-web/src/core/presentation/components/ui/Avatar.tsx +++ b/client-web/src/core/presentation/components/ui/Avatar.tsx @@ -40,7 +40,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP /> ) : (
{initials}
diff --git a/client-web/src/core/presentation/components/ui/ImageLightbox.tsx b/client-web/src/core/presentation/components/ui/ImageLightbox.tsx index d0d5bb4..14a9a54 100644 --- a/client-web/src/core/presentation/components/ui/ImageLightbox.tsx +++ b/client-web/src/core/presentation/components/ui/ImageLightbox.tsx @@ -46,7 +46,7 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose } initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 z-[9999] bg-black flex items-center justify-center" + className="fixed inset-0 z-[20000] bg-black flex items-center justify-center" onClick={onClose} > {/* Top bar */} diff --git a/client-web/src/core/presentation/layouts/SideMenu.tsx b/client-web/src/core/presentation/layouts/SideMenu.tsx index d7cde90..700aa0d 100644 --- a/client-web/src/core/presentation/layouts/SideMenu.tsx +++ b/client-web/src/core/presentation/layouts/SideMenu.tsx @@ -165,9 +165,9 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {/* ── Premium header with avatar ── */}
{/* Animated gradient backdrop */} -
-
-
+
+
+
@@ -178,9 +178,8 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {user?.avatar ? ( ) : ( -
-
- {initials} +
+ {initials}
)}
@@ -223,7 +222,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {item.subtitle &&

{item.subtitle}

}
{'badge' in item && item.badge ? ( - + {item.badge} ) : ( @@ -521,7 +520,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {u.avatar ? ( ) : ( -
+
{(u.displayName || u.username || '?')[0].toUpperCase()}
)} @@ -556,7 +555,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {req.user.avatar ? ( ) : ( -
+
{(req.user.displayName || req.user.username || '?')[0].toUpperCase()}
)} @@ -601,7 +600,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {friend.avatar ? ( ) : ( -
+
{(friend.displayName || friend.username || '?')[0].toUpperCase()}
)} diff --git a/client-web/src/core/utils/utils.ts b/client-web/src/core/utils/utils.ts index 7a4c0f9..78dac4c 100644 --- a/client-web/src/core/utils/utils.ts +++ b/client-web/src/core/utils/utils.ts @@ -71,14 +71,14 @@ export function getInitials(name: string): string { export function generateAvatarColor(name: string): string { const colors = [ - 'from-violet-500 to-purple-600', - 'from-blue-500 to-indigo-600', - 'from-emerald-500 to-teal-600', - 'from-rose-500 to-pink-600', - 'from-amber-500 to-orange-600', - 'from-cyan-500 to-blue-600', - 'from-fuchsia-500 to-purple-600', - 'from-lime-500 to-green-600', + 'bg-linear-to-br from-blue-400 to-blue-600', + 'bg-linear-to-br from-sky-400 to-sky-600', + 'bg-linear-to-br from-cyan-400 to-blue-600', + 'bg-linear-to-br from-blue-500 to-indigo-600', + 'bg-linear-to-br from-emerald-400 to-blue-600', + 'bg-linear-to-br from-blue-600 to-primary-container', + 'bg-linear-to-br from-primary to-primary-container', + 'bg-linear-to-br from-blue-400 to-cyan-500', ]; let hash = 0; diff --git a/client-web/src/index.css b/client-web/src/index.css index d0448d4..4484e64 100644 --- a/client-web/src/index.css +++ b/client-web/src/index.css @@ -1,234 +1,246 @@ -@import "tailwindcss"; - -:root { - color-scheme: dark; - --primary: #9acbff; - --primary-container: #3096e5; - --on-primary: #003355; - --on-primary-container: #e0f2ff; - - --secondary: #c7c6ca; - --secondary-container: #48494c; - --on-secondary: #2f3033; - --on-secondary-container: #e3e2e6; - - --tertiary: #53e16f; - --tertiary-container: #006e25; - --on-tertiary: #00390a; - --on-tertiary-container: #affca0; - - --error: #ffb4ab; - --error-container: #93000a; - --on-error: #690005; - --on-error-container: #ffdad6; - - --surface: #131313; - --surface-container-lowest: #0b0b0b; - --surface-container-low: #1b1b1b; - --surface-container: #201f1f; - --surface-container-high: #2a2a2a; - --surface-container-highest: #353535; - - --on-surface: #f5f5fa; - --on-surface-variant: #b4b4c3; - --outline: #3c3c4b; - --outline-variant: #2d2d37; - - --ease-ice: cubic-bezier(0.4, 0, 0.2, 1); - --shadow-ambient: 0 40px 80px -20px rgba(0, 0, 0, 0.5); -} - -@theme { - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-headline: 'Inter', sans-serif; - --font-body: 'Inter', sans-serif; - - --color-primary: var(--primary); - --color-primary-container: var(--primary-container); - --color-on-primary: var(--on-primary); - --color-on-primary-container: var(--on-primary-container); - - --color-secondary: var(--secondary); - --color-secondary-container: var(--secondary-container); - --color-on-secondary: var(--on-secondary); - --color-on-secondary-container: var(--on-secondary-container); - - --color-tertiary: var(--tertiary); - --color-tertiary-container: var(--tertiary-container); - --color-on-tertiary: var(--on-tertiary); - --color-on-tertiary-container: var(--on-tertiary-container); - - --color-error: var(--error); - --color-error-container: var(--error-container); - --color-on-error: var(--on-error); - --color-on-error-container: var(--on-error-container); - - --color-surface: var(--surface); - --color-surface-container-lowest: var(--surface-container-lowest); - --color-surface-container-low: var(--surface-container-low); - --color-surface-container: var(--surface-container); - --color-surface-container-high: var(--surface-container-high); - --color-surface-container-highest: var(--surface-container-highest); - - --color-on-surface: var(--on-surface); - --color-on-surface-variant: var(--on-surface-variant); - --color-outline: var(--outline); - --color-outline-variant: var(--outline-variant); - - --animate-kinetic-in: slideInUp 0.6s var(--ease-ice) forwards; -} - -/* Global resets and base styles */ -html, -body, -#root { - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; - background-color: #131313 !important; - /* Force to Obsidian surface */ - color: #f5f5fa !important; - /* Force to high-contrast white */ - font-family: var(--font-sans); - -webkit-font-smoothing: antialiased; -} - -/* Base style for all elements if they inherit color incorrectly */ -* { - box-sizing: border-box; -} - -/* Kinetic Core Components */ -.knot-card { - background-color: var(--surface-container-low) !important; - border-radius: 2rem; - /* 32px */ - transition: all 0.4s var(--ease-ice); - overflow: hidden; -} - -.knot-card-active { - background-color: var(--surface-container) !important; -} - -.knot-input-group { - background-color: rgba(53, 53, 53, 0.4) !important; - border-radius: 1.75rem; - padding: 1.25rem 1.6rem; - border: 1px solid rgba(154, 203, 255, 0.1); - transition: all 0.4s var(--ease-ice); - display: flex; - flex-direction: column; - backdrop-filter: blur(10px); -} - -.knot-input-group:focus-within { - background-color: rgba(53, 53, 53, 0.6) !important; - border-color: #9acbff !important; - box-shadow: 0 0 0 1px #9acbff, 0 0 40px rgba(154, 203, 255, 0.1); -} - -.knot-input-group input { - background: transparent !important; - border: none !important; - outline: none !important; - width: 100%; - color: #f5f5fa !important; -} - -.knot-button-primary { - background: linear-gradient(135deg, #9acbff, #3096e5) !important; - color: #e0f2ff !important; - border-radius: 1.5rem; - font-weight: 900; - letter-spacing: 0.05em; - transition: all 0.4s var(--ease-ice); -} - -.knot-button-primary:hover { - transform: translateY(-2px); - filter: brightness(1.1); - box-shadow: 0 24px 32px -12px rgba(48, 150, 229, 0.4); -} - -/* Utilities */ -.glass-effect { - backdrop-filter: blur(20px); - background: rgba(19, 19, 19, 0.6); -} - -.slide-on-ice { - transition: all 0.4s var(--ease-ice); -} - -.animate-kinetic { - animation: var(--animate-kinetic-in); -} - -.custom-scrollbar::-webkit-scrollbar { - width: 4px; -} - -.custom-scrollbar::-webkit-scrollbar-thumb { - background: var(--outline-variant); - border-radius: 10px; -} - -/* Overrides for legacy code */ -.bg-surface { - background-color: var(--surface) !important; -} - -.bg-surface-container-low { - background-color: var(--surface-container-low) !important; -} - -.bg-surface-container-lowest { - background-color: var(--surface-container-lowest) !important; -} - -.bg-surface-container-high { - background-color: var(--surface-container-high) !important; -} - -.text-on-surface { - color: var(--on-surface) !important; -} - -.text-on-surface-variant { - color: var(--on-surface-variant) !important; -} - -.text-primary { - color: var(--primary) !important; -} - -.text-accent { - color: #9acbff !important; -} - -/* Bubble overrides */ -.bubble-sent { - background: linear-gradient(135deg, #9acbff, #3096e5) !important; - color: #e0f2ff !important; - border-radius: 1.5rem; - border-bottom-right-radius: 0.25rem; -} - -.bubble-received { - background: var(--surface-container-highest) !important; - color: var(--on-surface) !important; - border-radius: 1.5rem; - border-bottom-left-radius: 0.25rem; -} - -/* Autofill fix - prevent browser blue background on dark theme */ -input:-webkit-autofill, -input:-webkit-autofill:hover, -input:-webkit-autofill:focus, -input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 1000px #0b0b0b inset !important; - -webkit-text-fill-color: #f5f5fa !important; - transition: background-color 5000s ease-in-out 0s; -} \ No newline at end of file +@import "tailwindcss"; + +:root { + color-scheme: dark; + --primary: #9acbff; + --primary-container: #3096e5; + --on-primary: #003355; + --on-primary-container: #e0f2ff; + + --secondary: #c7c6ca; + --secondary-container: #48494c; + --on-secondary: #2f3033; + --on-secondary-container: #e3e2e6; + + --tertiary: #53e16f; + --tertiary-container: #006e25; + --on-tertiary: #00390a; + --on-tertiary-container: #affca0; + + --error: #ffb4ab; + --error-container: #93000a; + --on-error: #690005; + --on-error-container: #ffdad6; + + --surface: #131313; + --surface-container-lowest: #0b0b0b; + --surface-container-low: #1b1b1b; + --surface-container: #201f1f; + --surface-container-high: #2a2a2a; + --surface-container-highest: #353535; + + --on-surface: #f5f5fa; + --on-surface-variant: #b4b4c3; + --outline: #3c3c4b; + --outline-variant: #2d2d37; + + --ease-ice: cubic-bezier(0.4, 0, 0.2, 1); + --shadow-ambient: 0 40px 80px -20px rgba(0, 0, 0, 0.5); +} + +@theme { + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-headline: 'Inter', sans-serif; + --font-body: 'Inter', sans-serif; + + --color-primary: var(--primary); + --color-primary-container: var(--primary-container); + --color-on-primary: var(--on-primary); + --color-on-primary-container: var(--on-primary-container); + + --color-secondary: var(--secondary); + --color-secondary-container: var(--secondary-container); + --color-on-secondary: var(--on-secondary); + --color-on-secondary-container: var(--on-secondary-container); + + --color-tertiary: var(--tertiary); + --color-tertiary-container: var(--tertiary-container); + --color-on-tertiary: var(--on-tertiary); + --color-on-tertiary-container: var(--on-tertiary-container); + + --color-error: var(--error); + --color-error-container: var(--error-container); + --color-on-error: var(--on-error); + --color-on-error-container: var(--on-error-container); + + --color-surface: var(--surface); + --color-surface-container-lowest: var(--surface-container-lowest); + --color-surface-container-low: var(--surface-container-low); + --color-surface-container: var(--surface-container); + --color-surface-container-high: var(--surface-container-high); + --color-surface-container-highest: var(--surface-container-highest); + + --color-on-surface: var(--on-surface); + --color-on-surface-variant: var(--on-surface-variant); + --color-outline: var(--outline); + --color-outline-variant: var(--outline-variant); + + --animate-kinetic-in: slideInUp 0.6s var(--ease-ice) forwards; +} + +/* Global resets and base styles */ +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + background-color: #131313 !important; + /* Force to Obsidian surface */ + color: #f5f5fa !important; + /* Force to high-contrast white */ + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* Base style for all elements if they inherit color incorrectly */ +* { + box-sizing: border-box; +} + +/* Kinetic Core Components */ +.knot-card { + background-color: var(--surface-container-low) !important; + border-radius: 2rem; + /* 32px */ + transition: all 0.4s var(--ease-ice); + overflow: hidden; +} + +.knot-card-active { + background-color: var(--surface-container) !important; +} + +.knot-input-group { + background-color: rgba(53, 53, 53, 0.4) !important; + border-radius: 1.75rem; + padding: 1.25rem 1.6rem; + border: 1px solid rgba(154, 203, 255, 0.1); + transition: all 0.4s var(--ease-ice); + display: flex; + flex-direction: column; + backdrop-filter: blur(10px); +} + +.knot-input-group:focus-within { + background-color: rgba(53, 53, 53, 0.6) !important; + border-color: #9acbff !important; + box-shadow: 0 0 0 1px #9acbff, 0 0 40px rgba(154, 203, 255, 0.1); +} + +.knot-input-group input { + background: transparent !important; + border: none !important; + outline: none !important; + width: 100%; + color: #f5f5fa !important; +} + +.knot-button-primary { + background: linear-gradient(135deg, #9acbff, #3096e5) !important; + color: #e0f2ff !important; + border-radius: 1.5rem; + font-weight: 900; + letter-spacing: 0.05em; + transition: all 0.4s var(--ease-ice); +} + +.knot-button-primary:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: 0 24px 32px -12px rgba(48, 150, 229, 0.4); +} + +/* Utilities */ +.glass-effect { + backdrop-filter: blur(20px); + background: rgba(19, 19, 19, 0.6); +} + +.slide-on-ice { + transition: all 0.4s var(--ease-ice); +} + +.animate-kinetic { + animation: var(--animate-kinetic-in); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--outline-variant); + border-radius: 10px; +} + +/* Overrides for legacy code */ +.bg-surface { + background-color: var(--surface) !important; +} + +.bg-surface-container-low { + background-color: var(--surface-container-low) !important; +} + +.bg-surface-container-lowest { + background-color: var(--surface-container-lowest) !important; +} + +.bg-surface-container-high { + background-color: var(--surface-container-high) !important; +} + +.text-on-surface { + color: var(--on-surface) !important; +} + +.text-on-surface-variant { + color: var(--on-surface-variant) !important; +} + +.text-primary { + color: var(--primary) !important; +} + +.text-accent { + color: #9acbff !important; +} + +/* Bubble overrides */ +.bubble-sent { + background: linear-gradient(135deg, #9acbff, #3096e5) !important; + color: #e0f2ff !important; + border-radius: 1.5rem; + border-bottom-right-radius: 0.25rem; +} + +.bubble-received { + background: var(--surface-container-highest) !important; + color: var(--on-surface) !important; + border-radius: 1.5rem; + border-bottom-left-radius: 0.25rem; +} + +/* Autofill fix - prevent browser blue background on dark theme */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 1000px #0b0b0b inset !important; + -webkit-text-fill-color: #f5f5fa !important; + transition: background-color 5000s ease-in-out 0s; +} + +@keyframes highlightFlash { + 0% { background-color: rgba(0, 163, 255, 0.3); } + 100% { background-color: transparent; } +} + +.highlight-message { + animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important; + border-radius: 12px; + position: relative; + z-index: 10; +} \ No newline at end of file diff --git a/client-web/src/modules/calls/presentation/components/CallModal.tsx b/client-web/src/modules/calls/presentation/components/CallModal.tsx index ae92964..29fe9fc 100644 --- a/client-web/src/modules/calls/presentation/components/CallModal.tsx +++ b/client-web/src/modules/calls/presentation/components/CallModal.tsx @@ -1591,7 +1591,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi {displayAvatar ? ( ) : ( -
+
{initials}
)} @@ -1735,7 +1735,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi {displayAvatar ? ( ) : ( -
+
{initials}
)} @@ -1851,7 +1851,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi {displayAvatar ? ( ) : ( -
+
{initials}
)} diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index 95aea6f..0f88c0a 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -471,7 +471,7 @@ export default function ChatPage() { >
-
+
{incomingGroupCall.callerInfo?.avatar ? ( ) : ( diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index b940705..c3d6cd3 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -649,7 +649,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal {showSearch ? close : search} - {!isFavorites && config?.enableCalls && ( + {!isFavorites && config?.webRtc?.enabled && ( <> - {config?.enableKlipy && onSelectGif && ( + {config?.klipy?.enabled && ( )}
- {/* Emoji tab */} {tab === 'emoji' && ( - onSelect(e.native)} - theme="dark" - locale={lang === 'ru' ? 'ru' : 'en'} - set="native" - previewPosition="none" - skinTonePosition="search" - perLine={9} - emojiSize={28} - emojiButtonSize={36} - maxFrequentRows={2} - navPosition="top" - dynamicWidth={false} - /> +
+ onSelect(e.native)} + theme="dark" + locale={lang === 'ru' ? 'ru' : 'en'} + set="native" + previewPosition="none" + skinTonePosition="search" + perLine={10} + emojiSize={26} + emojiButtonSize={34} + maxFrequentRows={2} + navPosition="top" + dynamicWidth={false} + background="transparent" + /> +
)} - {/* GIF tab */} - {config?.enableKlipy && tab === 'gif' && ( -
-
+ {config?.klipy?.enabled && tab === 'gif' && ( +
+ {/* Search Bar */} +
handleGifSearch(e.target.value)} - placeholder={t('searchGifs')} - className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors" + placeholder={lang === 'ru' ? 'Поиск GIF...' : 'Search GIFs...'} + className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/5 text-xs text-white placeholder-zinc-500 border border-white/5 focus:border-primary/50 outline-none transition-all shadow-inner" />
- {!gifQuery.trim() && !gifLoading && ( -
- - {t('trending')} -
- )} -
- {gifLoading ? ( -
- -
- ) : displayGifs.length === 0 ? ( -

{gifQuery ? t('nothingFound') : ''}

+ + {/* Sub-Tabs */} +
+ + + + {gifMode === 'search' && ( +
+ + {gifQuery} +
+ )} +
+ + {/* Content Area */} +
+ {gifMode === 'categories' ? ( + categoriesLoading ? ( +
+ ) : ( +
+ {categories.map((cat, i) => ( + + ))} +
+ ) + ) : displayGifs.length === 0 && gifLoading ? ( +
) : ( -
- {displayGifs.map((gif) => ( - - ))} -
+ <> +
+ {displayGifs.map((gif, idx) => ( + + ))} +
+ {gifLoading && ( +
+ )} + {!currentHasMore && !gifLoading && displayGifs.length > 0 && ( +

{lang === 'ru' ? 'Больше нет результатов' : 'No more results'}

+ )} + {!gifLoading && displayGifs.length === 0 && ( +
+ +

{gifMode === 'recent' ? (lang === 'ru' ? 'Пусто в истории' : 'Empty history') : (lang === 'ru' ? 'Ничего не найдено' : 'Nothing found')}

+
+ )} + )}
diff --git a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx index 59981bf..4b151b4 100644 --- a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx +++ b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx @@ -362,7 +362,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet className="w-32 h-32 rounded-full object-cover shadow-inner" /> ) : ( -
+
{initials}
)} @@ -577,7 +577,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet {u.avatar ? ( ) : ( -
+
{(u.displayName || u.username || '?')[0].toUpperCase()}
)} @@ -616,7 +616,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet {member.user.avatar ? ( ) : ( -
+
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
)} diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index efab3be..63c4ced 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -314,7 +314,7 @@ function MessageBubble({ reactionGroups[r.emoji].avatars.push({ url: r.user?.avatar, initials: displayName[0].toUpperCase(), - colorClass: generateAvatarColor(displayName) + colorClass: 'from-primary/80 to-primary-container' }); } if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true; @@ -338,7 +338,7 @@ function MessageBubble({ href={part} target="_blank" rel="noopener noreferrer" - className="text-sky-400 hover:underline" + className="text-white underline decoration-white/30 underline-offset-2 hover:decoration-white transition-all" onClick={(e) => e.stopPropagation()} > {part} @@ -400,7 +400,7 @@ function MessageBubble({ {senderAvatar ? ( ) : ( -
+
{senderName[0]?.toUpperCase() || '?'}
)} @@ -847,7 +847,7 @@ function MessageBubble({ {senderAvatar ? ( ) : ( -
+
{senderName[0]?.toUpperCase() || '?'}
)} diff --git a/client-web/src/modules/chats/presentation/components/MessageInput.tsx b/client-web/src/modules/chats/presentation/components/MessageInput.tsx index 1a8df44..2b21ea8 100644 --- a/client-web/src/modules/chats/presentation/components/MessageInput.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageInput.tsx @@ -764,7 +764,7 @@ export default function MessageInput({ chatId }: MessageInputProps) { onClick={() => imageInputRef.current?.click()} className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group" > -
+
{t('photoVideo')} @@ -833,7 +833,7 @@ export default function MessageInput({ chatId }: MessageInputProps) { i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5' }`} > -
+
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
diff --git a/client-web/src/modules/chats/presentation/components/NewChatModal.tsx b/client-web/src/modules/chats/presentation/components/NewChatModal.tsx index 72a7df8..0966fb1 100644 --- a/client-web/src/modules/chats/presentation/components/NewChatModal.tsx +++ b/client-web/src/modules/chats/presentation/components/NewChatModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react'; +import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react'; import { ChatApi } from '../../infrastructure/chatApi'; import { UserApi } from '../../../users/infrastructure/userApi'; import { FriendApi } from '../../../friends/infrastructure/friendApi'; @@ -112,10 +112,10 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={(e) => e.target === e.currentTarget && onClose()} > -
+
{/* Шапка */} -
-
+
+
{mode !== 'personal' && ( )} -

+

{mode === 'personal' ? t('newChatTitle') : mode === 'group-select' @@ -140,23 +140,25 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {

{mode === 'group-name' ? ( /* Шаг 2: Назвать группу */
- setGroupName(e.target.value)} - className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors" - autoFocus - /> +
+ setGroupName(e.target.value)} + className="w-full px-6 py-4 rounded-2xl bg-surface-container-highest/30 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 focus:bg-surface-container-highest/50 outline-none transition-all shadow-inner" + autoFocus + /> +

{t('membersCount')} ({selectedUsers.length}): @@ -170,7 +172,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { {u.avatar ? ( ) : ( -

+
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase()}
)} @@ -188,13 +190,13 @@ export default function NewChatModal({ onClose }: NewChatModalProps) { @@ -237,8 +239,8 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
)} -
- +
+ setQuery(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors" + className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/20 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 outline-none transition-all focus:bg-surface-container-highest/30" autoFocus />
@@ -261,45 +263,47 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
) : query.trim().length >= 3 && users.length > 0 ? ( - users.map((u) => ( -
-
-

- {u.displayName || u.userName || u.username || ''} -

-

@{u.userName || u.username || ''}

-
- {mode === 'group-select' && ( -
- {isSelected(u.id) && } -
- )} - - )) + + ))} +
) : query.trim().length >= 3 && users.length === 0 ? (

{t('usersNotFound')}

@@ -310,46 +314,48 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
) : friends.length > 0 ? ( <> -

{t('friends')}

- {friends.map((u) => ( -
-
-

- {u.displayName || u.userName || u.username || ''} -

-

@{u.userName || u.username || ''}

-
- {mode === 'group-select' && ( -
- {isSelected(u.id) && } -
- )} - - ))} + + ))} +
) : (
diff --git a/client-web/src/modules/friends/application/friendStore.ts b/client-web/src/modules/friends/application/friendStore.ts index f5d3c09..d1f5141 100644 --- a/client-web/src/modules/friends/application/friendStore.ts +++ b/client-web/src/modules/friends/application/friendStore.ts @@ -95,9 +95,7 @@ export const useFriendStore = create((set, get) => ({ const socket = getSocket(); if (socket) socket.emit('friend_request', { friendId }); - if (result.status === 'accepted') { - await get().loadFriends(); - } + await get().loadFriends(); set((state) => ({ searchResults: state.searchResults.filter(u => u.id !== friendId) })); diff --git a/client-web/src/modules/users/presentation/components/UserProfile.tsx b/client-web/src/modules/users/presentation/components/UserProfile.tsx index a7ab47d..d55d407 100644 --- a/client-web/src/modules/users/presentation/components/UserProfile.tsx +++ b/client-web/src/modules/users/presentation/components/UserProfile.tsx @@ -1,1089 +1,407 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, MessageSquare, Phone, MoreHorizontal, UserMinus, + UserPlus, UserCheck, Calendar, Info, BellOff, + Image as ImageIcon, Film, FileText, Link as LinkIcon, + Search, ExternalLink, Download +} from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react'; -import Cropper from 'react-easy-crop'; -import { UserApi } from '../../infrastructure/userApi'; -import { ChatApi } from '../../../chats/infrastructure/chatApi'; -import { FriendApi } from '../../../friends/infrastructure/friendApi'; -import { StoryApi } from '../../../stories/infrastructure/storyApi'; -import { useAuthStore } from '../../../auth/application/authStore'; import { useLang } from '../../../../core/infrastructure/i18n'; -import { User, Message, FriendshipStatus, StoryGroup } from '../../../../core/domain/types'; -import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal'; +import { useAuthStore } from '../../../auth/application/authStore'; +import { useFriendStore } from '../../../friends/application/friendStore'; +import { ChatApi } from '../../../chats/infrastructure/chatApi'; +import { httpClient } from '../../../../core/infrastructure/httpClient'; +import { Loader2 } from 'lucide-react'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; -import StoryViewer from '../../../stories/presentation/components/StoryViewer'; -import { getSocket } from '../../../../core/infrastructure/socket'; -import { useStoryStore } from '../../../stories/application/storyStore'; -import { getMediaUrl } from '../../../../core/utils/utils'; -import { getCroppedImg } from '../../../../core/infrastructure/imageCrop'; -import DatePicker from '../../../../core/presentation/components/ui/DatePicker'; -import { useChatStore } from '../../../chats/application/chatStore'; -import { toggleMuteChat, isChatMuted } from '../../../../core/utils/sounds'; +import type { FriendWithId, FriendRequest } from '../../../../core/domain/types'; interface UserProfileProps { userId: string; - chatId?: string; onClose: () => void; - onGoToMessage?: (messageId: string) => void; + onMessage?: (userId: string) => void; isSelf?: boolean; + chatId?: string; + onGoToMessage?: (msgId: any, createdAt: string) => Promise; } -type MediaTab = 'publications' | 'gifs' | 'media' | 'files' | 'links'; +type TabType = 'media' | 'gif' | 'files' | 'links'; -export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) { - const { user: authUser, config } = useAuthStore(); - const { t, lang } = useLang(); - const [profile, setProfile] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [activeTab, setActiveTab] = useState('publications'); +export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelfProp, chatId, onGoToMessage }: UserProfileProps) { + const { lang } = useLang(); + const { user: currentUser } = useAuthStore(); + const { friends, friendRequests, sendRequest, removeFriend, loadFriends } = useFriendStore(); - const { chats, addChat, setActiveChat, loadMessages } = useChatStore(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - const personalChat = chats.find(c => c.type === 'personal' && c.members.some(m => m.user.id === userId)); - const commonGroups = chats.filter(c => c.type === 'group' && c.members.some(m => m.user.id === userId)); + const [activeTab, setActiveTab] = useState('media'); + const [tabData, setTabData] = useState([]); + const [counts, setCounts] = useState>({}); + const [countsLoading, setCountsLoading] = useState(true); + const [contentLoading, setContentLoading] = useState(false); - const [isMutedLocally, setIsMutedLocally] = useState(() => personalChat ? isChatMuted(personalChat.id) : false); + // Lightbox state + const [lightbox, setLightbox] = useState<{ open: boolean; index: number }>({ open: false, index: 0 }); - useEffect(() => { - if (personalChat) setIsMutedLocally(isChatMuted(personalChat.id)); - }, [personalChat?.id]); - - const handleOpenChat = async () => { + const targetId = useMemo(() => userId?.toLowerCase(), [userId]); + const isMe = useMemo(() => isSelfProp || currentUser?.id?.toLowerCase() === targetId, [isSelfProp, currentUser?.id, targetId]); + + const fetchUser = useCallback(async () => { try { - let targetChatId = personalChat?.id; - if (!targetChatId) { - const chat = await ChatApi.createPersonalChat(userId); - addChat(chat); - const socket = getSocket(); - if (socket) socket.emit('join_chat', chat.id); - targetChatId = chat.id; - } - setActiveChat(targetChatId); - loadMessages(targetChatId); - onClose(); - } catch (e) { - console.error(e); - } - }; - - const handleToggleMute = () => { - if (!personalChat) return; - const muted = toggleMuteChat(personalChat.id); - setIsMutedLocally(muted); - }; - - const handleStartCall = (type: 'voice' | 'video') => { - if (profile) { - window.dispatchEvent(new CustomEvent('START_CALL', { detail: { targetUser: profile, type } })); - onClose(); - } - }; - - // Shared media state - const [sharedMedia, setSharedMedia] = useState([]); - const [sharedGifs, setSharedGifs] = useState([]); - const [sharedFiles, setSharedFiles] = useState([]); - const [sharedLinks, setSharedLinks] = useState>([]); - const [tabLoading, setTabLoading] = useState(false); - const [userStories, setUserStories] = useState([]); // Changed from StoryGroup | null to any[] as per instruction - const [loadedTabs, setLoadedTabs] = useState>(new Set()); - const [lightboxIndex, setLightboxIndex] = useState(null); - const { openViewer } = useStoryStore(); - const [storyViewerOpen, setStoryViewerOpen] = useState(false); - const [initialStoryIdx, setInitialStoryIdx] = useState(0); - - // Friend state - const [friendStatus, setFriendStatus] = useState(null); - const [friendLoading, setFriendLoading] = useState(false); - - // Profile Edit State - const [isEditing, setIsEditing] = useState(false); - const [displayName, setDisplayName] = useState(''); - const [bio, setBio] = useState(''); - const [birthday, setBirthday] = useState(''); - const [isSaving, setIsSaving] = useState(false); - - // Avatar edit state - const [isCropping, setIsCropping] = useState(false); - const [cropFile, setCropFile] = useState(null); - const [cropImage, setCropImage] = useState(null); - const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); - - const API_URL = import.meta.env.VITE_API_URL || ''; - - const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({ - ...m, - url: getMediaUrl(m.url), - messageId: msg.id, - createdAt: msg.createdAt - }))); - - const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({ - ...m, - url: getMediaUrl(m.url), - messageId: msg.id, - createdAt: msg.createdAt - }))); - - const isMediaGif = (m: any) => { - if (m.type === 'gif') return true; - if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true; - if (m.filename?.toLowerCase().includes('gif')) return true; - if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true; - return false; - }; - - const pureMedia = allMedia.filter(m => !isMediaGif(m)); - const combinedGifsSet = new Map(); - allGifs.forEach(m => combinedGifsSet.set(m.id, m)); - allMedia.filter(isMediaGif).forEach(m => combinedGifsSet.set(m.id, m)); - const pureGifs = Array.from(combinedGifsSet.values()); - - const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const sortedMedia = [...pureMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const sortedGifs = [...pureGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - const renderGrouped = ( - sortedItems: T[], - renderItem: (item: T, originalIndex: number) => React.ReactNode, - gridClass?: string - ) => { - let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null; - const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = []; - - sortedItems.forEach((item, idx) => { - const date = new Date(item.createdAt); - const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }); - if (currentGroup?.dateStr !== dateStr) { - currentGroup = { dateStr, items: [] }; - groups.push(currentGroup); - } - currentGroup.items.push({item, idx}); - }); - - return ( -
- {groups.map((g, i) => ( -
-
- {g.dateStr} -
-
- {g.items.map(({item, idx}) => renderItem(item, idx))} -
-
- ))} -
- ); - }; - - useEffect(() => { - loadProfile(); - if (!isSelf) { - FriendApi.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {}); - } - }, [userId, isSelf]); - - // Load shared media/files/links when tab changes - const loadTabData = useCallback(async (tab: MediaTab) => { - if (loadedTabs.has(tab)) return; - setTabLoading(true); - try { - if (tab === 'publications') { - const data = await StoryApi.getUserStories(userId); - setUserStories(data.stories || []); - } else if (chatId) { // Only load media/files/links if chatId is available - const data = await ChatApi.getSharedMedia(chatId, tab); - if (tab === 'media') setSharedMedia(data); - else if (tab === 'gifs') setSharedGifs(data); - else if (tab === 'files') setSharedFiles(data); - else setSharedLinks(data); - } - setLoadedTabs(prev => new Set(prev).add(tab)); - } catch (e) { - console.error('Failed to load shared', tab, e); - } finally { - setTabLoading(false); - } - }, [chatId, userId, loadedTabs]); - - useEffect(() => { - const tabsToLoad: MediaTab[] = ['publications']; - if (chatId) { - tabsToLoad.push('gifs', 'media', 'files', 'links'); - } - tabsToLoad.forEach(tab => loadTabData(tab)); - }, [chatId, loadTabData]); - - const loadProfile = async () => { - try { - setIsLoading(true); - if (isSelf && authUser) { - setProfile(authUser); - setDisplayName(authUser.displayName || ''); - setBio(authUser.bio || ''); - setBirthday(authUser.birthday || ''); - } else { - const data = await UserApi.getUser(userId); - setProfile(data); - if (isSelf) { - setDisplayName(data.displayName || ''); - setBio(data.bio || ''); - setBirthday(data.birthday || ''); - } - } + const res = await httpClient.request(`/profiles/${userId}`); + setUser(res); } catch (e) { console.error(e); } finally { - setIsLoading(false); + setLoading(false); } - }; + }, [userId]); - const handleSave = async () => { + const loadSummary = useCallback(async () => { + if (!chatId) { + setCountsLoading(false); + return; + } try { - setIsSaving(true); - const dateToSave = birthday ? new Date(birthday).toISOString() : undefined; - const updated = await UserApi.updateProfile({ - displayName: displayName.trim(), - bio: bio.trim(), - birthday: dateToSave, + const types: TabType[] = ['media', 'gif', 'files', 'links']; + const data = await Promise.all( + types.map(t => ChatApi.getSharedMedia(chatId, t === 'gif' ? 'gifs' : t)) + ); + + const newCounts: Record = {}; + types.forEach((t, i) => { + newCounts[t] = data[i].length; }); - setProfile(updated); - useAuthStore.getState().updateUser(updated); - setIsEditing(false); - } catch (e) { - console.error(e); - } finally { - setIsSaving(false); - } - }; - - const handleSendFriendRequest = async () => { - try { - setFriendLoading(true); - const result = await FriendApi.sendFriendRequest(userId); - if (result.status === 'accepted') { - setFriendStatus({ status: 'accepted', friendshipId: null }); - } else { - setFriendStatus({ status: 'pending', friendshipId: null, direction: 'outgoing' }); + + setCounts(newCounts); + + const available = types.filter(t => newCounts[t] > 0); + if (available.length > 0 && !available.includes(activeTab)) { + setActiveTab(available[0]); } - // Notify via socket - const socket = getSocket(); - if (socket) socket.emit('friend_request', { friendId: userId }); } catch (e) { console.error(e); } finally { - setFriendLoading(false); + setCountsLoading(false); } - }; + }, [chatId, activeTab]); - const handleAcceptFriend = async () => { - if (!friendStatus?.friendshipId) return; + const loadCurrentTabContent = useCallback(async () => { + if (!chatId) return; + setContentLoading(true); try { - setFriendLoading(true); - await FriendApi.acceptFriendRequest(friendStatus.friendshipId); - setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId }); - const socket = getSocket(); - if (socket) socket.emit('friend_accepted', { friendId: userId }); + const data = await ChatApi.getSharedMedia(chatId, activeTab === 'gif' ? 'gifs' : activeTab); + setTabData(data); } catch (e) { console.error(e); } finally { - setFriendLoading(false); + setContentLoading(false); } - }; - - const handleRemoveFriend = async () => { - if (!friendStatus?.friendshipId) return; - try { - setFriendLoading(true); - await FriendApi.removeFriend(friendStatus.friendshipId); - setFriendStatus({ status: 'none', friendshipId: null }); - const socket = getSocket(); - if (socket) socket.emit('friend_removed', { friendId: userId }); - } catch (e) { - console.error(e); - } finally { - setFriendLoading(false); - } - }; - - const handleAvatarSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - setCropFile(file); - const url = URL.createObjectURL(file); - setCropImage(url); - setIsCropping(true); - }; - - const handleCropSave = async () => { - if (!cropImage || !croppedAreaPixels) return; - - try { - setTabLoading(true); // Reusing tabLoading for avatar upload - - const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels); - if (!croppedFile) throw new Error("Could not crop image"); - - const updatedUser = await UserApi.uploadAvatar(croppedFile); - setProfile(updatedUser); - useAuthStore.getState().updateUser(updatedUser); - setIsCropping(false); - setCropImage(null); - setCropFile(null); - setCroppedAreaPixels(null); - setCrop({ x: 0, y: 0 }); - setZoom(1); - } catch (e) { - console.error('Failed to save avatar', e); - } finally { - setTabLoading(false); - } - }; - - const handleRemoveAvatar = async () => { - try { - setTabLoading(true); - await UserApi.removeAvatar(); - const updatedUser = { ...profile!, avatar: null, avatarUrl: null }; - setProfile(updatedUser); - useAuthStore.getState().updateUser({ avatar: null, avatarUrl: null }); - } catch (err) { - console.error(err); - } finally { - setTabLoading(false); - } - }; - - const initials = (profile?.displayName || profile?.userName || profile?.username || '??') - .split(' ') - .map((w: string) => w[0]) - .join('') - .slice(0, 2) - .toUpperCase(); - - const tabsConfig = [ - { key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length }, - ...(chatId && !isSelf ? [ - { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length }, - { key: 'gifs' as const, label: 'GIF', icon: Play, count: sortedGifs.length }, - { key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length }, - { key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length }, - ] : []), - ]; - - const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0); + }, [chatId, activeTab]); useEffect(() => { - const expected = chatId ? 5 : 1; - if (loadedTabs.size === expected && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) { - setActiveTab(availableTabs[0].key as any); - } - }, [loadedTabs, activeTab, chatId, availableTabs]); + fetchUser(); + loadSummary(); + loadFriends(); + }, [fetchUser, loadSummary, loadFriends]); - return ( - <> - { + loadCurrentTabContent(); + }, [loadCurrentTabContent]); + + const friend = useMemo(() => friends.find((f: FriendWithId) => f.id?.toLowerCase() === targetId), [friends, targetId]); + const outgoingReq = useMemo(() => friendRequests.find((r: FriendRequest) => r.user?.id?.toLowerCase() === targetId && r.isOutgoing), [friendRequests, targetId]); + const incomingReq = useMemo(() => friendRequests.find((r: FriendRequest) => r.user?.id?.toLowerCase() === targetId && !r.isOutgoing), [friendRequests, targetId]); + + const formatRegistrationDate = (dateStr: string) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + if (lang === 'ru') { + const monthsGenitive = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' + ]; + return `${date.getDate()} ${monthsGenitive[date.getMonth()]} ${date.getFullYear()} г.`; + } + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'long', year: 'numeric' }); + }; + + const resolveUrl = (url?: string) => { + if (!url) return ''; + if (url.startsWith('http')) return url; + const baseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/api$/, ''); + return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`; + }; + + const formatSize = (bytes?: number) => { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024; + i++; + } + return `${bytes.toFixed(1)} ${units[i]}`; + }; + + const handleFriendAction = async () => { + if (friend) { + await removeFriend(friend.friendshipId); + } else if (!outgoingReq && !incomingReq) { + await sendRequest(userId); + } + }; + + const availableTabs = useMemo(() => { + const all: { id: TabType, icon: any, label: string }[] = [ + { id: 'media', icon: ImageIcon, label: lang === 'ru' ? 'Медиа' : 'Media' }, + { id: 'gif', icon: Film, label: 'GIF' }, + { id: 'files', icon: FileText, label: lang === 'ru' ? 'Файлы' : 'Files' }, + { id: 'links', icon: LinkIcon, label: lang === 'ru' ? 'Ссылки' : 'Links' } + ]; + return isMe ? all : all.filter(t => counts[t.id] > 0); + }, [counts, lang, isMe]); + + // Gallery items for Lightbox + const galleryItems = useMemo(() => { + return tabData + .flatMap(item => { + if (!item.media || item.media.length === 0) return []; + return item.media.map((m: any) => ({ + id: m.id, + url: resolveUrl(m.url), + type: (m.type?.toLowerCase() === 'video' || m.url.toLowerCase().endsWith('.mp4')) ? 'video' : 'image', + originalItem: item + })); + }) + .filter(i => { + if (activeTab === 'media') { + const isGif = i.type === 'video' && (i.url.toLowerCase().endsWith('.mp4') || i.url.toLowerCase().indexOf('klipy') !== -1); + const isStandardImageGif = i.type === 'image' && i.url.toLowerCase().endsWith('.gif'); + return !(isGif || isStandardImageGif); + } + return true; + }); + }, [tabData, activeTab]); + + const openInLightbox = (idx: number) => { + setLightbox({ open: true, index: idx }); + }; + + return createPortal( + + - e.stopPropagation()} + className="relative w-full max-w-[560px] h-[85vh] bg-[#0a0a0a] rounded-[3rem] border border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl" > - {/* Шапка */} -
-

- {(isSelf ? t('myProfile') : t('profileTitle')) as string} -

-
- {isSelf && ( - !isEditing ? ( - - ) : ( - - ) - )} - -
+
+ {lang === 'ru' ? 'ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ' : 'USER PROFILE'} +
- {isLoading ? ( -
-
-
- ) : profile ? ( -
- -
- {/* Аватар */} -
-
- -
- { (profile.avatarUrl || profile.avatar) ? ( - - ) : ( -
- {initials} -
- )} -
- - {isSelf && ( -
- -
- )} - - {isSelf && profile.avatar && ( - - )} - -
- - {/* Имя */} - {isEditing ? ( -
-
- setDisplayName(e.target.value)} - placeholder={t('enterName')} - className="relative text-lg font-bold text-center text-white bg-black/40 border border-white/20 outline-none px-4 py-2.5 w-full rounded-2xl transition-colors focus:bg-black/60 focus:border-knot-400 placeholder-white/30 truncate" - /> -
- ) : ( -

- {profile.displayName || profile.userName || profile.username || ''} -

- )} - - {/* Username (неизменяемый) */} -
- - {profile.userName || profile.username || ''} -
- - {/* Онлайн статус */} -

- {profile.isOnline ? ( - - - {t('online')} - - ) : ( - - - {t('wasRecently')} - - )} -

- - {/* Friend button (for other users only) */} - {!isSelf && friendStatus && ( -
- {friendStatus.status === 'none' && ( - - )} - {friendStatus.status === 'pending' && friendStatus.direction === 'outgoing' && ( -
- - {t('requestSent')} -
- )} - {friendStatus.status === 'pending' && friendStatus.direction === 'incoming' && ( -
- - -
- )} - {friendStatus.status === 'accepted' && ( - - )} -
- )} - - {/* Быстрые действия (только для других пользователей) */} - {!isSelf && ( -
- - - {config?.enableCalls && ( - - )} - -
- )} -
- - {/* Информация */} -
- {/* О себе */} -
-
-
- -
- -
- {isEditing ? ( -