Доработки профиля
@@ -3,24 +3,28 @@ FROM node:20-alpine AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# ROOT package.json
|
# Copy package files for apps/web
|
||||||
COPY package*.json ./
|
# We use the local package files to avoid workspace hoisting issues in Docker
|
||||||
COPY apps/web/package*.json ./apps/web/
|
COPY apps/web/package.json ./
|
||||||
|
# If there is a lockfile in apps/web, use it, otherwise use root (but root is workspace, so better not)
|
||||||
|
# Let's try to generate a clean install
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
RUN npm install
|
# Copy source code
|
||||||
|
COPY apps/web/ ./
|
||||||
|
|
||||||
# Copy source
|
# Build args
|
||||||
COPY . .
|
ARG VITE_KLIPY_API_KEY
|
||||||
|
ENV VITE_KLIPY_API_KEY=$VITE_KLIPY_API_KEY
|
||||||
|
|
||||||
# Build web frontend
|
# Build web frontend
|
||||||
WORKDIR /app/apps/web
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Serve with Nginx
|
# Stage 2: Serve with Nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Copy built assets
|
# Copy built assets
|
||||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Custom Nginx config to proxy /api and /socket.io to server:3001
|
# Custom Nginx config to proxy /api and /socket.io to server:3001
|
||||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand
|
|||||||
{
|
{
|
||||||
var chat = Chat.Create(request.Name, request.Type);
|
var chat = Chat.Create(request.Name, request.Type);
|
||||||
|
|
||||||
foreach (var userId in request.MemberIds)
|
for (int i = 0; i < request.MemberIds.Count; i++)
|
||||||
{
|
{
|
||||||
chat.AddMember(userId, ChatRole.Member);
|
var userId = request.MemberIds[i];
|
||||||
|
var role = (i == 0 && request.Type == ChatType.Group) ? ChatRole.Admin : ChatRole.Member;
|
||||||
|
chat.AddMember(userId, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
_chatRepository.Add(chat);
|
_chatRepository.Add(chat);
|
||||||
|
|||||||
@@ -32,17 +32,19 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
{
|
{
|
||||||
public ChatType Type { get; private set; }
|
public ChatType Type { get; private set; }
|
||||||
public string? Name { get; private set; }
|
public string? Name { get; private set; }
|
||||||
|
public string? Description { get; private set; }
|
||||||
public string? Avatar { get; private set; }
|
public string? Avatar { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
private readonly List<ChatMember> _members = new();
|
private readonly List<ChatMember> _members = new();
|
||||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||||
|
|
||||||
private Chat(Guid id, ChatType type, string? name, string? avatar) : base(id)
|
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
||||||
{
|
{
|
||||||
Type = type;
|
Type = type;
|
||||||
Name = name;
|
Name = name;
|
||||||
Avatar = avatar;
|
Avatar = avatar;
|
||||||
|
Description = description;
|
||||||
CreatedAt = DateTime.UtcNow;
|
CreatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +71,9 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Фабричный метод для создания чата.
|
/// Фабричный метод для создания чата.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Chat Create(string? name, ChatType type, string? avatar = null)
|
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
||||||
{
|
{
|
||||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar);
|
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
||||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,8 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
|
|
||||||
public void UpdateName(string name) => Name = name;
|
public void UpdateName(string name) => Name = name;
|
||||||
|
|
||||||
|
public void UpdateDescription(string? description) => Description = description;
|
||||||
|
|
||||||
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
Size = m.Size
|
Size = m.Size
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
Sender = senderObj,
|
Sender = senderObj,
|
||||||
Reactions = new List<object>(),
|
ReadBy = new List<object>(),
|
||||||
ReadBy = new List<object>()
|
message.StoryId,
|
||||||
|
message.StoryMediaUrl,
|
||||||
|
message.StoryMediaType
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Vortex.Modules.Chats.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ChatsDbContext))]
|
||||||
|
[Migration("20260313204219_AddChatDescription")]
|
||||||
|
partial class AddChatDescription
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("chats")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Chats", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("uuid[]")
|
||||||
|
.HasColumnName("DeletedByUsers");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ForwardedFromId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEdited")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Quote")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplyToId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("StoryId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("StoryMediaType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("StoryMediaUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Messages", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Emoji")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId", "UserId", "Emoji")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MessageReactions", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReadAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ReadReceipts", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Vortex.Modules.Chats.Domain.ChatMember", "Members", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<DateTime>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b1.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("ChatId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("ChatMembers", "chats");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("ChatId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<string>("Filename")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<long?>("Size")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.HasKey("MessageId", "Id");
|
||||||
|
|
||||||
|
b1.ToTable("MessageMedia", "chats");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("MessageId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Media");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
|
||||||
|
.WithMany("Reactions")
|
||||||
|
.HasForeignKey("MessageId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
|
||||||
|
.WithMany("ReadBy")
|
||||||
|
.HasForeignKey("MessageId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Reactions");
|
||||||
|
|
||||||
|
b.Navigation("ReadBy");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChatDescription : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Description",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Description",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|||||||
@@ -383,8 +383,9 @@ public sealed class ChatHub : Hub
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HubMethodName("group_call_leave")]
|
[HubMethodName("group_call_leave")]
|
||||||
public async Task GroupCallLeave(string chatId)
|
public async Task GroupCallLeave(GroupLeaveRequest request)
|
||||||
{
|
{
|
||||||
|
var chatId = request.ChatId;
|
||||||
var userId = _userContext.UserId.ToString();
|
var userId = _userContext.UserId.ToString();
|
||||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||||
{
|
{
|
||||||
@@ -457,6 +458,84 @@ public sealed class ChatHub : Hub
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HubMethodName("group_call_status")]
|
||||||
|
public async Task GroupCallStatus(GroupCallStatusRequest request)
|
||||||
|
{
|
||||||
|
var chatId = request.ChatId;
|
||||||
|
var userId = _userContext.UserId.ToString();
|
||||||
|
|
||||||
|
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||||
|
{
|
||||||
|
if (participants.TryGetValue(userId, out var info))
|
||||||
|
{
|
||||||
|
// Update local state if needed (e.g. muted status)
|
||||||
|
participants[userId] = info with
|
||||||
|
{
|
||||||
|
IsMuted = request.IsMuted,
|
||||||
|
IsVideoOff = request.IsVideoOff
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new {
|
||||||
|
chatId = request.ChatId,
|
||||||
|
userId = Context.UserIdentifier,
|
||||||
|
isMuted = request.IsMuted,
|
||||||
|
isVideoOff = request.IsVideoOff
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HubMethodName("group_call_status_params")]
|
||||||
|
public async Task GroupCallStatusParams(string chatId, bool isMuted, bool isVideoOff)
|
||||||
|
{
|
||||||
|
var userId = _userContext.UserId.ToString();
|
||||||
|
|
||||||
|
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||||
|
{
|
||||||
|
if (participants.TryGetValue(userId, out var info))
|
||||||
|
{
|
||||||
|
// Update local state if needed (e.g. muted status)
|
||||||
|
participants[userId] = info with
|
||||||
|
{
|
||||||
|
IsMuted = isMuted,
|
||||||
|
IsVideoOff = isVideoOff
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Clients.Group(chatId).SendAsync("group_call_status_updated", new {
|
||||||
|
chatId = chatId,
|
||||||
|
userId = Context.UserIdentifier,
|
||||||
|
isMuted = isMuted,
|
||||||
|
isVideoOff = isVideoOff
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HubMethodName("get_group_call_status")]
|
||||||
|
public async Task GetGroupCallStatus(GetGroupCallStatusRequest request)
|
||||||
|
{
|
||||||
|
var chatId = request.ChatId;
|
||||||
|
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||||
|
{
|
||||||
|
var others = participants.Values.ToList();
|
||||||
|
_logger.LogInformation("Found {Count} participants for chat {ChatId}", others.Count, chatId);
|
||||||
|
await Clients.Caller.SendAsync("group_call_active", new
|
||||||
|
{
|
||||||
|
chatId = chatId,
|
||||||
|
participants = others.Select(p => p.Id).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No active call for chat {ChatId}", chatId);
|
||||||
|
await Clients.Caller.SendAsync("group_call_active", new
|
||||||
|
{
|
||||||
|
chatId = chatId,
|
||||||
|
participants = new List<string>()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HubMethodName("screen_share_started")]
|
[HubMethodName("screen_share_started")]
|
||||||
public async Task ScreenShareStarted(string chatId)
|
public async Task ScreenShareStarted(string chatId)
|
||||||
{
|
{
|
||||||
@@ -523,7 +602,10 @@ public sealed class ChatHub : Hub
|
|||||||
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||||
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
public record GroupCallJoinRequest(string ChatId, string CallType);
|
||||||
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false);
|
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
||||||
|
public record GroupLeaveRequest(string ChatId);
|
||||||
|
public record GetGroupCallStatusRequest(string ChatId);
|
||||||
|
public record GroupCallStatusRequest(string ChatId, bool IsMuted, bool IsVideoOff);
|
||||||
public record GroupCallOfferRequest(string ChatId, string TargetUserId, object Offer);
|
public record GroupCallOfferRequest(string ChatId, string TargetUserId, object Offer);
|
||||||
public record GroupCallAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
public record GroupCallAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||||
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ using Vortex.Shared.Kernel;
|
|||||||
using Vortex.Modules.Identity.Domain;
|
using Vortex.Modules.Identity.Domain;
|
||||||
using Vortex.Modules.Identity.Application.Abstractions;
|
using Vortex.Modules.Identity.Application.Abstractions;
|
||||||
using Vortex.Modules.Chats.Infrastructure.Persistence;
|
using Vortex.Modules.Chats.Infrastructure.Persistence;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
namespace Vortex.Host.Controllers;
|
namespace Vortex.Host.Controllers;
|
||||||
|
|
||||||
@@ -100,6 +102,9 @@ public sealed class ChatsController : ControllerBase
|
|||||||
m.Type,
|
m.Type,
|
||||||
m.ReplyToId,
|
m.ReplyToId,
|
||||||
m.Quote,
|
m.Quote,
|
||||||
|
m.StoryId,
|
||||||
|
m.StoryMediaUrl,
|
||||||
|
m.StoryMediaType,
|
||||||
m.IsEdited,
|
m.IsEdited,
|
||||||
m.IsDeleted,
|
m.IsDeleted,
|
||||||
m.CreatedAt,
|
m.CreatedAt,
|
||||||
@@ -119,7 +124,8 @@ public sealed class ChatsController : ControllerBase
|
|||||||
{
|
{
|
||||||
id = c.Id,
|
id = c.Id,
|
||||||
type = c.Type.ToString().ToLowerInvariant(),
|
type = c.Type.ToString().ToLowerInvariant(),
|
||||||
name = c.Type == ChatType.Favorites ? "Избранное" : c.Name,
|
name = c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name),
|
||||||
|
description = c.Description,
|
||||||
avatar = c.Avatar,
|
avatar = c.Avatar,
|
||||||
createdAt = c.CreatedAt,
|
createdAt = c.CreatedAt,
|
||||||
members = members,
|
members = members,
|
||||||
@@ -160,10 +166,11 @@ public sealed class ChatsController : ControllerBase
|
|||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, CancellationToken ct)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var memberIds = request.MemberIds.ToList();
|
var memberIds = request.MemberIds.ToList();
|
||||||
if (!memberIds.Contains(_userContext.UserId))
|
if (memberIds.Contains(_userContext.UserId))
|
||||||
{
|
{
|
||||||
memberIds.Add(_userContext.UserId);
|
memberIds.Remove(_userContext.UserId);
|
||||||
}
|
}
|
||||||
|
memberIds.Insert(0, _userContext.UserId);
|
||||||
|
|
||||||
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
|
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
|
||||||
Result<Guid> result = await _sender.Send(command, ct);
|
Result<Guid> result = await _sender.Send(command, ct);
|
||||||
@@ -191,6 +198,7 @@ public sealed class ChatsController : ControllerBase
|
|||||||
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
|
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
|
||||||
|
|
||||||
if (request.Name != null) chat.UpdateName(request.Name);
|
if (request.Name != null) chat.UpdateName(request.Name);
|
||||||
|
if (request.Description != null) chat.UpdateDescription(request.Description);
|
||||||
chatRepository.Update(chat);
|
chatRepository.Update(chat);
|
||||||
await uow.SaveChangesAsync(ct);
|
await uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -294,6 +302,47 @@ public sealed class ChatsController : ControllerBase
|
|||||||
return Ok(await MapChatAsync(id, ct));
|
return Ok(await MapChatAsync(id, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/avatar/crop")]
|
||||||
|
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var chat = await chatRepository.GetByIdAsync(id, ct);
|
||||||
|
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
|
||||||
|
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
|
||||||
|
|
||||||
|
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
|
||||||
|
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}.jpg";
|
||||||
|
var filePath = Path.Combine(uploadsPath, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var inputStream = avatar.OpenReadStream())
|
||||||
|
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
|
||||||
|
{
|
||||||
|
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
|
||||||
|
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
|
||||||
|
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
|
||||||
|
int rectHeight = Math.Max(1, Math.Min(height, image.Height - startY));
|
||||||
|
|
||||||
|
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
|
||||||
|
image.Mutate(ctx => ctx.Resize(400, 400));
|
||||||
|
await image.SaveAsJpegAsync(filePath, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, "Error processing image: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"/uploads/avatars/{fileName}";
|
||||||
|
chat.UpdateAvatar(url);
|
||||||
|
chatRepository.Update(chat);
|
||||||
|
await uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(await MapChatAsync(id, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}/avatar")]
|
[HttpDelete("{id:guid}/avatar")]
|
||||||
public async Task<IActionResult> RemoveGroupAvatar(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
|
public async Task<IActionResult> RemoveGroupAvatar(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -310,6 +359,7 @@ public sealed class ChatsController : ControllerBase
|
|||||||
private async Task<object> MapChatAsync(Guid chatId, CancellationToken ct)
|
private async Task<object> MapChatAsync(Guid chatId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var chatRepository = HttpContext.RequestServices.GetRequiredService<IChatRepository>();
|
var chatRepository = HttpContext.RequestServices.GetRequiredService<IChatRepository>();
|
||||||
|
var messageRepository = HttpContext.RequestServices.GetRequiredService<IMessageRepository>();
|
||||||
var chat = await chatRepository.GetByIdAsync(chatId, ct);
|
var chat = await chatRepository.GetByIdAsync(chatId, ct);
|
||||||
if (chat == null) return new { };
|
if (chat == null) return new { };
|
||||||
|
|
||||||
@@ -334,15 +384,48 @@ public sealed class ChatsController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var chatMessages = await messageRepository.GetChatMessagesAsync(chatId, 1, 0, ct);
|
||||||
|
var messagesList = new List<object>();
|
||||||
|
if (chatMessages.Any())
|
||||||
|
{
|
||||||
|
var m = chatMessages.First();
|
||||||
|
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct);
|
||||||
|
messagesList.Add(new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
m.ChatId,
|
||||||
|
m.SenderId,
|
||||||
|
m.Content,
|
||||||
|
m.Type,
|
||||||
|
m.ReplyToId,
|
||||||
|
m.Quote,
|
||||||
|
m.StoryId,
|
||||||
|
m.StoryMediaUrl,
|
||||||
|
m.StoryMediaType,
|
||||||
|
m.IsEdited,
|
||||||
|
m.IsDeleted,
|
||||||
|
m.CreatedAt,
|
||||||
|
Media = m.Media.ToList(),
|
||||||
|
Sender = senderObj != null ? new {
|
||||||
|
id = senderObj.Id,
|
||||||
|
username = senderObj.Username,
|
||||||
|
displayName = senderObj.DisplayName,
|
||||||
|
avatar = senderObj.Avatar
|
||||||
|
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
|
||||||
|
ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
id = chat.Id,
|
id = chat.Id,
|
||||||
type = chat.Type.ToString().ToLowerInvariant(),
|
type = chat.Type.ToString().ToLowerInvariant(),
|
||||||
name = chat.Type == ChatType.Favorites ? "Избранное" : chat.Name,
|
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||||
|
description = chat.Description,
|
||||||
avatar = chat.Avatar,
|
avatar = chat.Avatar,
|
||||||
createdAt = chat.CreatedAt,
|
createdAt = chat.CreatedAt,
|
||||||
members = members,
|
members = members,
|
||||||
messages = new List<object>(),
|
messages = messagesList,
|
||||||
unreadCount = 0
|
unreadCount = 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -351,6 +434,6 @@ public sealed class ChatsController : ControllerBase
|
|||||||
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);
|
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);
|
||||||
public sealed record CreatePersonalChatRequest(Guid UserId);
|
public sealed record CreatePersonalChatRequest(Guid UserId);
|
||||||
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);
|
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);
|
||||||
public sealed record UpdateChatRequest(string? Name);
|
public sealed record UpdateChatRequest(string? Name, string? Description);
|
||||||
public sealed record AddMembersRequest(List<Guid> UserIds);
|
public sealed record AddMembersRequest(List<Guid> UserIds);
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ public sealed class MessagesController : ControllerBase
|
|||||||
avatar = fwd.Avatar
|
avatar = fwd.Avatar
|
||||||
} : null,
|
} : null,
|
||||||
storyId = m.StoryId,
|
storyId = m.StoryId,
|
||||||
|
storyMediaUrl = m.StoryMediaUrl,
|
||||||
|
storyMediaType = m.StoryMediaType,
|
||||||
media = m.Media.Select(media => new {
|
media = m.Media.Select(media => new {
|
||||||
media.Id,
|
media.Id,
|
||||||
media.Type,
|
media.Type,
|
||||||
@@ -161,6 +163,9 @@ public sealed class MessagesController : ControllerBase
|
|||||||
displayName = fwd.DisplayName,
|
displayName = fwd.DisplayName,
|
||||||
avatar = fwd.Avatar
|
avatar = fwd.Avatar
|
||||||
} : null,
|
} : null,
|
||||||
|
storyId = m.StoryId,
|
||||||
|
storyMediaUrl = m.StoryMediaUrl,
|
||||||
|
storyMediaType = m.StoryMediaType,
|
||||||
media = m.Media.Select(media => new {
|
media = m.Media.Select(media => new {
|
||||||
media.Id,
|
media.Id,
|
||||||
media.Type,
|
media.Type,
|
||||||
@@ -217,7 +222,7 @@ public sealed class MessagesController : ControllerBase
|
|||||||
{
|
{
|
||||||
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
|
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
|
||||||
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>();
|
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>();
|
||||||
var mediaLinks = m.Media.Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
|
var mediaLinks = (m.Media ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
|
||||||
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
|
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
|
||||||
|
|
||||||
if (allLinks.Any())
|
if (allLinks.Any())
|
||||||
@@ -249,9 +254,12 @@ public sealed class MessagesController : ControllerBase
|
|||||||
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
|
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
|
||||||
result.Add(new
|
result.Add(new
|
||||||
{
|
{
|
||||||
m.Id,
|
m.ReplyToId,
|
||||||
m.ChatId,
|
m.Quote,
|
||||||
m.SenderId,
|
m.StoryId,
|
||||||
|
m.StoryMediaUrl,
|
||||||
|
m.StoryMediaType,
|
||||||
|
m.IsEdited,
|
||||||
m.Content,
|
m.Content,
|
||||||
m.Type,
|
m.Type,
|
||||||
m.CreatedAt,
|
m.CreatedAt,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Vortex.Modules.Identity.Domain;
|
using Vortex.Modules.Identity.Domain;
|
||||||
using Vortex.Shared.Kernel;
|
using Vortex.Shared.Kernel;
|
||||||
using Vortex.Modules.Identity.Application.Abstractions;
|
using Vortex.Modules.Identity.Application.Abstractions;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
namespace Vortex.Host.Controllers;
|
namespace Vortex.Host.Controllers;
|
||||||
|
|
||||||
@@ -121,6 +123,58 @@ public sealed class UsersController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("avatar/crop")]
|
||||||
|
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
|
||||||
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
|
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
|
||||||
|
|
||||||
|
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "avatars");
|
||||||
|
if (!Directory.Exists(uploadsPath)) Directory.CreateDirectory(uploadsPath);
|
||||||
|
|
||||||
|
var ext = ".jpg";
|
||||||
|
var fileName = $"{Guid.NewGuid()}{ext}";
|
||||||
|
var filePath = Path.Combine(uploadsPath, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var inputStream = avatar.OpenReadStream())
|
||||||
|
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
|
||||||
|
{
|
||||||
|
// Clamp coordinates to image bounds
|
||||||
|
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
|
||||||
|
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
|
||||||
|
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
|
||||||
|
int rectHeight = Math.Max(1, Math.Min(height, image.Height - startY));
|
||||||
|
|
||||||
|
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
|
||||||
|
image.Mutate(ctx => ctx.Resize(400, 400));
|
||||||
|
|
||||||
|
await image.SaveAsJpegAsync(filePath, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, "Error processing image: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var avatarUrl = $"/uploads/avatars/{fileName}";
|
||||||
|
user.UpdateAvatar(avatarUrl);
|
||||||
|
await _unitOfWork.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
id = user.Id,
|
||||||
|
username = user.Username,
|
||||||
|
displayName = user.DisplayName,
|
||||||
|
avatar = user.Avatar,
|
||||||
|
bio = user.Bio,
|
||||||
|
birthday = user.Birthday,
|
||||||
|
createdAt = user.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("avatar")]
|
[HttpDelete("avatar")]
|
||||||
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
|
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"Microsoft.EntityFrameworkCore.Design": "10.0.4",
|
"Microsoft.EntityFrameworkCore.Design": "10.0.4",
|
||||||
"Microsoft.IdentityModel.Tokens": "8.16.0",
|
"Microsoft.IdentityModel.Tokens": "8.16.0",
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL": "10.0.0",
|
"Npgsql.EntityFrameworkCore.PostgreSQL": "10.0.0",
|
||||||
|
"SixLabors.ImageSharp": "3.1.12",
|
||||||
"Swashbuckle.AspNetCore": "10.1.5",
|
"Swashbuckle.AspNetCore": "10.1.5",
|
||||||
"System.IdentityModel.Tokens.Jwt": "8.16.0",
|
"System.IdentityModel.Tokens.Jwt": "8.16.0",
|
||||||
"Vortex.Modules.Chats": "1.0.0",
|
"Vortex.Modules.Chats": "1.0.0",
|
||||||
@@ -741,6 +742,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp/3.1.12": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||||
|
"assemblyVersion": "3.0.0.0",
|
||||||
|
"fileVersion": "3.1.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/10.1.5": {
|
"Swashbuckle.AspNetCore/10.1.5": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Swashbuckle.AspNetCore.Swagger": "10.1.5",
|
"Swashbuckle.AspNetCore.Swagger": "10.1.5",
|
||||||
@@ -1252,6 +1261,13 @@
|
|||||||
"path": "npgsql.entityframeworkcore.postgresql/10.0.0",
|
"path": "npgsql.entityframeworkcore.postgresql/10.0.0",
|
||||||
"hashPath": "npgsql.entityframeworkcore.postgresql.10.0.0.nupkg.sha512"
|
"hashPath": "npgsql.entityframeworkcore.postgresql.10.0.0.nupkg.sha512"
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp/3.1.12": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
|
||||||
|
"path": "sixlabors.imagesharp/3.1.12",
|
||||||
|
"hashPath": "sixlabors.imagesharp.3.1.12.nupkg.sha512"
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/10.1.5": {
|
"Swashbuckle.AspNetCore/10.1.5": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"serviceable": true,
|
"serviceable": true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Host")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+ca1d88191ca628dbe2371abab56fb3cdfe386d65")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+010b96d362a728b28bae56836e464103371899f4")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Host")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
f3d52bfd2b858d5d383380a9e4100efe419d613a4ecf13cf06c4ad546122d06b
|
42cfa915aa235da6705c7cf95cb1a14d6010145730d282552fab9d0c3500b1eb
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
a0a268746b849e72488e8608dc7529251d735d933091a22e5f93d38662ef7534
|
d31a690f913188ddcf07b749ff1ed53974efbd88f60dacca614c62e22165ddc7
|
||||||
|
|||||||
@@ -158,3 +158,4 @@ E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\System.Ide
|
|||||||
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.AspNetCore.Authentication.JwtBearer.dll
|
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.AspNetCore.Authentication.JwtBearer.dll
|
||||||
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.IdentityModel.Protocols.dll
|
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.IdentityModel.Protocols.dll
|
||||||
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.IdentityModel.Protocols.OpenIdConnect.dll
|
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\Microsoft.IdentityModel.Protocols.OpenIdConnect.dll
|
||||||
|
E:\GIT\forkmessager\apps\server-net\src\Vortex.Host\bin\Debug\net10.0\SixLabors.ImageSharp.dll
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","zR9GPKJ4P6VsVoHQ2DUOzlUgEbOZ6Jtpw3dF8z1T3G8="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||||
@@ -1 +1 @@
|
|||||||
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","zR9GPKJ4P6VsVoHQ2DUOzlUgEbOZ6Jtpw3dF8z1T3G8="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||||
@@ -1994,6 +1994,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[10.0.0, )"
|
"version": "[10.0.0, )"
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[3.1.12, )"
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore": {
|
"Swashbuckle.AspNetCore": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[10.1.5, )"
|
"version": "[10.1.5, )"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore\10.0.4\buildTransitive\net10.0\Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore\10.0.4\buildTransitive\net10.0\Microsoft.EntityFrameworkCore.props')" />
|
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore\10.0.4\buildTransitive\net10.0\Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore\10.0.4\buildTransitive\net10.0\Microsoft.EntityFrameworkCore.props')" />
|
||||||
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server\10.0.0\build\Microsoft.Extensions.ApiDescription.Server.props" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server\10.0.0\build\Microsoft.Extensions.ApiDescription.Server.props')" />
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server\10.0.0\build\Microsoft.Extensions.ApiDescription.Server.props" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server\10.0.0\build\Microsoft.Extensions.ApiDescription.Server.props')" />
|
||||||
<Import Project="$(NuGetPackageRoot)swashbuckle.aspnetcore\10.1.5\build\Swashbuckle.AspNetCore.props" Condition="Exists('$(NuGetPackageRoot)swashbuckle.aspnetcore\10.1.5\build\Swashbuckle.AspNetCore.props')" />
|
<Import Project="$(NuGetPackageRoot)swashbuckle.aspnetcore\10.1.5\build\Swashbuckle.AspNetCore.props" Condition="Exists('$(NuGetPackageRoot)swashbuckle.aspnetcore\10.1.5\build\Swashbuckle.AspNetCore.props')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)sixlabors.imagesharp\3.1.12\build\SixLabors.ImageSharp.props" Condition="Exists('$(NuGetPackageRoot)sixlabors.imagesharp\3.1.12\build\SixLabors.ImageSharp.props')" />
|
||||||
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.analyzers\3.11.0\buildTransitive\Microsoft.CodeAnalysis.Analyzers.props" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.analyzers\3.11.0\buildTransitive\Microsoft.CodeAnalysis.Analyzers.props')" />
|
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.analyzers\3.11.0\buildTransitive\Microsoft.CodeAnalysis.Analyzers.props" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.analyzers\3.11.0\buildTransitive\Microsoft.CodeAnalysis.Analyzers.props')" />
|
||||||
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore.design\10.0.4\build\net10.0\Microsoft.EntityFrameworkCore.Design.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore.design\10.0.4\build\net10.0\Microsoft.EntityFrameworkCore.Design.props')" />
|
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore.design\10.0.4\build\net10.0\Microsoft.EntityFrameworkCore.Design.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore.design\10.0.4\build\net10.0\Microsoft.EntityFrameworkCore.Design.props')" />
|
||||||
</ImportGroup>
|
</ImportGroup>
|
||||||
|
|||||||
@@ -1022,6 +1022,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp/3.1.12": {
|
||||||
|
"type": "package",
|
||||||
|
"compile": {
|
||||||
|
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||||
|
"related": ".xml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"build/SixLabors.ImageSharp.props": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/10.1.5": {
|
"Swashbuckle.AspNetCore/10.1.5": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3641,6 +3657,22 @@
|
|||||||
"postgresql.png"
|
"postgresql.png"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp/3.1.12": {
|
||||||
|
"sha512": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
|
||||||
|
"type": "package",
|
||||||
|
"path": "sixlabors.imagesharp/3.1.12",
|
||||||
|
"files": [
|
||||||
|
".nupkg.metadata",
|
||||||
|
".signature.p7s",
|
||||||
|
"LICENSE",
|
||||||
|
"build/SixLabors.ImageSharp.props",
|
||||||
|
"lib/net6.0/SixLabors.ImageSharp.dll",
|
||||||
|
"lib/net6.0/SixLabors.ImageSharp.xml",
|
||||||
|
"sixlabors.imagesharp.128.png",
|
||||||
|
"sixlabors.imagesharp.3.1.12.nupkg.sha512",
|
||||||
|
"sixlabors.imagesharp.nuspec"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore/10.1.5": {
|
"Swashbuckle.AspNetCore/10.1.5": {
|
||||||
"sha512": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==",
|
"sha512": "/eNk9z/8quXhDX14o3XLbwAX/84uIWSbiUD7cI/UrQnoBMOiyAtzKxNEJUtf/TyxjFpcXxE9FAfLvtbNpxHBSg==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
@@ -3962,6 +3994,7 @@
|
|||||||
"Microsoft.EntityFrameworkCore.Design >= 10.0.4",
|
"Microsoft.EntityFrameworkCore.Design >= 10.0.4",
|
||||||
"Microsoft.IdentityModel.Tokens >= 8.16.0",
|
"Microsoft.IdentityModel.Tokens >= 8.16.0",
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL >= 10.0.0",
|
"Npgsql.EntityFrameworkCore.PostgreSQL >= 10.0.0",
|
||||||
|
"SixLabors.ImageSharp >= 3.1.12",
|
||||||
"Swashbuckle.AspNetCore >= 10.1.5",
|
"Swashbuckle.AspNetCore >= 10.1.5",
|
||||||
"System.IdentityModel.Tokens.Jwt >= 8.16.0",
|
"System.IdentityModel.Tokens.Jwt >= 8.16.0",
|
||||||
"Vortex.Modules.Chats >= 1.0.0",
|
"Vortex.Modules.Chats >= 1.0.0",
|
||||||
@@ -4068,6 +4101,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[10.0.0, )"
|
"version": "[10.0.0, )"
|
||||||
},
|
},
|
||||||
|
"SixLabors.ImageSharp": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[3.1.12, )"
|
||||||
|
},
|
||||||
"Swashbuckle.AspNetCore": {
|
"Swashbuckle.AspNetCore": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[10.1.5, )"
|
"version": "[10.1.5, )"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"dgSpecHash": "Lr9L1t57HIQ=",
|
"dgSpecHash": "ghRzA2bAmsQ=",
|
||||||
"success": true,
|
"success": true,
|
||||||
"projectFilePath": "E:\\GIT\\forkmessager\\apps\\server-net\\src\\Vortex.Host\\Vortex.Host.csproj",
|
"projectFilePath": "E:\\GIT\\forkmessager\\apps\\server-net\\src\\Vortex.Host\\Vortex.Host.csproj",
|
||||||
"expectedPackageFiles": [
|
"expectedPackageFiles": [
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"C:\\Users\\HomePC\\.nuget\\packages\\newtonsoft.json\\13.0.3\\newtonsoft.json.13.0.3.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\newtonsoft.json\\13.0.3\\newtonsoft.json.13.0.3.nupkg.sha512",
|
||||||
"C:\\Users\\HomePC\\.nuget\\packages\\npgsql\\10.0.0\\npgsql.10.0.0.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\npgsql\\10.0.0\\npgsql.10.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\HomePC\\.nuget\\packages\\npgsql.entityframeworkcore.postgresql\\10.0.0\\npgsql.entityframeworkcore.postgresql.10.0.0.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\npgsql.entityframeworkcore.postgresql\\10.0.0\\npgsql.entityframeworkcore.postgresql.10.0.0.nupkg.sha512",
|
||||||
|
"C:\\Users\\HomePC\\.nuget\\packages\\sixlabors.imagesharp\\3.1.12\\sixlabors.imagesharp.3.1.12.nupkg.sha512",
|
||||||
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore\\10.1.5\\swashbuckle.aspnetcore.10.1.5.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore\\10.1.5\\swashbuckle.aspnetcore.10.1.5.nupkg.sha512",
|
||||||
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\10.1.5\\swashbuckle.aspnetcore.swagger.10.1.5.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\10.1.5\\swashbuckle.aspnetcore.swagger.10.1.5.nupkg.sha512",
|
||||||
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\10.1.5\\swashbuckle.aspnetcore.swaggergen.10.1.5.nupkg.sha512",
|
"C:\\Users\\HomePC\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\10.1.5\\swashbuckle.aspnetcore.swaggergen.10.1.5.nupkg.sha512",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 228 KiB |
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -19,9 +20,9 @@
|
|||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-easy-crop": "^5.5.6",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"zustand": "^5.0.2",
|
"zustand": "^5.0.2"
|
||||||
"@microsoft/signalr": "^10.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
socket.on('group_call_active', handler);
|
socket.on('group_call_active', handler);
|
||||||
// Request current status when opening a group chat
|
// Request current status when opening a group chat
|
||||||
if (activeChat && chat?.type === 'group') {
|
if (activeChat && chat?.type === 'group') {
|
||||||
socket.emit('group_call_status', { chatId: activeChat });
|
socket.emit('get_group_call_status', { chatId: activeChat });
|
||||||
}
|
}
|
||||||
return () => { socket.off('group_call_active', handler); };
|
return () => { socket.off('group_call_active', handler); };
|
||||||
}, [activeChat, user?.id, chat?.type]);
|
}, [activeChat, user?.id, chat?.type]);
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ import Picker from '@emoji-mart/react';
|
|||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
interface TenorGif {
|
interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
media_formats?: {
|
images: {
|
||||||
gif?: { url: string };
|
original: { url: string };
|
||||||
tinygif?: { url: string };
|
fixed_height_small: { url: string };
|
||||||
};
|
};
|
||||||
content_description?: string;
|
file: {
|
||||||
|
sd?: { gif?: { url: string }; webp?: { url: string } };
|
||||||
|
hd?: { gif?: { url: string }; webp?: { url: string } };
|
||||||
|
};
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
@@ -20,36 +25,65 @@ interface EmojiPickerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTenorKey = () => localStorage.getItem('vortex_tenor_key') || '';
|
const getKlipyKey = () => import.meta.env.VITE_KLIPY_API_KEY || '';
|
||||||
|
const getCustomerId = () => {
|
||||||
|
const user = useAuthStore.getState().user;
|
||||||
|
return user?.id || 'anonymous';
|
||||||
|
};
|
||||||
|
|
||||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||||
const { lang, t } = useLang();
|
const { lang, t } = useLang();
|
||||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||||
const [gifQuery, setGifQuery] = useState('');
|
const [gifQuery, setGifQuery] = useState('');
|
||||||
const [gifs, setGifs] = useState<TenorGif[]>([]);
|
const [gifs, setGifs] = useState<KlipyGif[]>([]);
|
||||||
const [gifLoading, setGifLoading] = useState(false);
|
const [gifLoading, setGifLoading] = useState(false);
|
||||||
const [trendingGifs, setTrendingGifs] = useState<TenorGif[]>([]);
|
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
|
||||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
// Load trending GIFs
|
// Helper to safely extract GIF array from various possible Klipy API responses
|
||||||
|
const extractGifs = (d: any): KlipyGif[] => {
|
||||||
|
if (!d) return [];
|
||||||
|
if (Array.isArray(d)) return d;
|
||||||
|
if (Array.isArray(d.data)) return d.data;
|
||||||
|
if (Array.isArray(d.result)) return d.result;
|
||||||
|
if (d.result && Array.isArray(d.result.data)) return d.result.data;
|
||||||
|
if (Array.isArray(d.gifs)) return d.gifs;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load trending GIFs (Klipy)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'gif' && getTenorKey() && trendingGifs.length === 0) {
|
if (tab === 'gif' && getKlipyKey() && trendingGifs.length === 0) {
|
||||||
setGifLoading(true);
|
setGifLoading(true);
|
||||||
fetch(`https://tenor.googleapis.com/v2/featured?key=${getTenorKey()}&limit=30&media_filter=gif,tinygif`)
|
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/trending?customer_id=${getCustomerId()}&per_page=30`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => { setTrendingGifs(d.results || []); setGifLoading(false); })
|
.then(d => {
|
||||||
.catch(() => setGifLoading(false));
|
setTrendingGifs(extractGifs(d));
|
||||||
|
setGifLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Klipy trending error:', e);
|
||||||
|
setTrendingGifs([]);
|
||||||
|
setGifLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [tab]);
|
}, [tab, trendingGifs.length]);
|
||||||
|
|
||||||
const searchGifs = useCallback((q: string) => {
|
const searchGifs = useCallback((q: string) => {
|
||||||
if (!getTenorKey() || !q.trim()) { setGifs([]); return; }
|
if (!getKlipyKey() || !q.trim()) { setGifs([]); return; }
|
||||||
setGifLoading(true);
|
setGifLoading(true);
|
||||||
fetch(`https://tenor.googleapis.com/v2/search?key=${getTenorKey()}&q=${encodeURIComponent(q)}&limit=30&media_filter=gif,tinygif`)
|
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/search?customer_id=${getCustomerId()}&q=${encodeURIComponent(q)}&per_page=30`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => { setGifs(d.results || []); setGifLoading(false); })
|
.then(d => {
|
||||||
.catch(() => setGifLoading(false));
|
setGifs(extractGifs(d));
|
||||||
|
setGifLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Klipy search error:', e);
|
||||||
|
setGifs([]);
|
||||||
|
setGifLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGifSearch = (q: string) => {
|
const handleGifSearch = (q: string) => {
|
||||||
@@ -58,9 +92,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickGif = (gif: TenorGif) => {
|
const pickGif = (gif: KlipyGif) => {
|
||||||
const url = gif.media_formats?.gif?.url || gif.media_formats?.tinygif?.url || '';
|
const url = gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url || '';
|
||||||
const preview = gif.media_formats?.tinygif?.url || url;
|
const preview = gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || url;
|
||||||
if (onSelectGif && url) {
|
if (onSelectGif && url) {
|
||||||
onSelectGif(url, preview);
|
onSelectGif(url, preview);
|
||||||
}
|
}
|
||||||
@@ -112,7 +146,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
>
|
>
|
||||||
EMOJI
|
EMOJI
|
||||||
</button>
|
</button>
|
||||||
{(getTenorKey() || onSelectGif) && (
|
{(getKlipyKey() || onSelectGif) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
||||||
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||||
@@ -144,12 +178,12 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
{/* GIF tab */}
|
{/* GIF tab */}
|
||||||
{tab === 'gif' && (
|
{tab === 'gif' && (
|
||||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||||
{!getTenorKey() ? (
|
{!getKlipyKey() ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||||
<p className="text-sm text-zinc-400 mb-2">{t('tenorKeyRequired')}</p>
|
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
|
||||||
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
|
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
|
||||||
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-vortex-400">
|
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-vortex-400">
|
||||||
localStorage.setItem('vortex_tenor_key', 'YOUR_KEY')
|
VITE_KLIPY_API_KEY in .env
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -188,8 +222,8 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
src={gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || gif.file?.hd?.gif?.url}
|
||||||
alt={gif.content_description || 'GIF'}
|
alt={gif.title || 'GIF'}
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Min
|
|||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
|
import { getMediaUrl } from '../lib/utils';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ interface ParticipantInfo {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
isSharingScreen?: boolean;
|
isSharingScreen?: boolean;
|
||||||
|
isMuted?: boolean;
|
||||||
|
isVideoOff?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerState {
|
interface PeerState {
|
||||||
@@ -234,22 +237,28 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
const toggleMic = useCallback(() => {
|
const toggleMic = useCallback(() => {
|
||||||
if (localStreamRef.current) {
|
if (localStreamRef.current) {
|
||||||
localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
|
localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
|
||||||
setIsMuted(m => !m);
|
const newMuted = !isMuted;
|
||||||
|
setIsMuted(newMuted);
|
||||||
|
const socket = getSocket();
|
||||||
|
socket?.emit('group_call_status', { chatId, isMuted: newMuted, isVideoOff });
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isMuted, isVideoOff, chatId]);
|
||||||
|
|
||||||
// Toggle video
|
// Toggle video
|
||||||
const toggleVideo = useCallback(async () => {
|
const toggleVideo = useCallback(async () => {
|
||||||
|
let newVideoOff = isVideoOff;
|
||||||
if (!isVideoOff) {
|
if (!isVideoOff) {
|
||||||
// Turn off video
|
// Turn off video
|
||||||
if (localStreamRef.current) {
|
if (localStreamRef.current) {
|
||||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
|
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
|
||||||
}
|
}
|
||||||
|
newVideoOff = true;
|
||||||
setIsVideoOff(true);
|
setIsVideoOff(true);
|
||||||
} else {
|
} else {
|
||||||
// Turn on video
|
// Turn on video
|
||||||
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
|
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
|
||||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
|
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
|
||||||
|
newVideoOff = false;
|
||||||
setIsVideoOff(false);
|
setIsVideoOff(false);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -265,12 +274,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||||
}
|
}
|
||||||
|
newVideoOff = false;
|
||||||
setIsVideoOff(false);
|
setIsVideoOff(false);
|
||||||
}
|
}
|
||||||
} catch { console.warn('Camera unavailable'); }
|
} catch { console.warn('Camera unavailable'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isVideoOff]);
|
const socket = getSocket();
|
||||||
|
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: newVideoOff });
|
||||||
|
}, [isVideoOff, isMuted, chatId]);
|
||||||
|
|
||||||
// Toggle screen share
|
// Toggle screen share
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const toggleScreenShare = useCallback(async () => {
|
||||||
@@ -648,6 +660,21 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: boolean; isVideoOff: boolean }) => {
|
||||||
|
if (data.chatId !== chatId) return;
|
||||||
|
setParticipants(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const p = next.get(data.userId);
|
||||||
|
if (p) next.set(data.userId, { ...p, isMuted: data.isMuted, isVideoOff: data.isVideoOff });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRequestStatus = (data: { chatId: string; requestedBy: string }) => {
|
||||||
|
if (data.chatId !== chatId) return;
|
||||||
|
socket.emit('group_call_status', { chatId, isMuted, isVideoOff });
|
||||||
|
};
|
||||||
|
|
||||||
socket.on('group_call_participants', onParticipants);
|
socket.on('group_call_participants', onParticipants);
|
||||||
socket.on('group_call_user_joined', onUserJoined);
|
socket.on('group_call_user_joined', onUserJoined);
|
||||||
socket.on('group_call_user_left', onUserLeft);
|
socket.on('group_call_user_left', onUserLeft);
|
||||||
@@ -658,6 +685,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
|
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||||
socket.on('screen_share_started', onScreenShareStarted);
|
socket.on('screen_share_started', onScreenShareStarted);
|
||||||
socket.on('screen_share_stopped', onScreenShareStopped);
|
socket.on('screen_share_stopped', onScreenShareStopped);
|
||||||
|
socket.on('group_call_status_updated', onStatusUpdated);
|
||||||
|
socket.on('group_call_request_status', onRequestStatus);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('group_call_participants', onParticipants);
|
socket.off('group_call_participants', onParticipants);
|
||||||
@@ -670,6 +699,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
|
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||||
socket.off('screen_share_started', onScreenShareStarted);
|
socket.off('screen_share_started', onScreenShareStarted);
|
||||||
socket.off('screen_share_stopped', onScreenShareStopped);
|
socket.off('screen_share_stopped', onScreenShareStopped);
|
||||||
|
socket.off('group_call_status_updated', onStatusUpdated);
|
||||||
|
socket.off('group_call_request_status', onRequestStatus);
|
||||||
};
|
};
|
||||||
}, [isOpen, chatId, createPeerConnection]);
|
}, [isOpen, chatId, createPeerConnection]);
|
||||||
|
|
||||||
@@ -871,7 +902,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
|
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
|
||||||
{hasVid ? (
|
{hasVid && !p.isVideoOff ? (
|
||||||
<video
|
<video
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
@@ -886,16 +917,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{p.avatar ? (
|
{p.avatar ? (
|
||||||
<img src={p.avatar} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
<img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{p.isMuted && <MicOff size={14} className="text-red-400" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%]">
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
|
||||||
{p.displayName || p.username}
|
{p.displayName || p.username} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,13 +11,25 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Crown,
|
Crown,
|
||||||
Users,
|
Users,
|
||||||
|
ImageIcon,
|
||||||
|
FileText,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Video
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { Chat, UserPresence } from '../lib/types';
|
import { Chat, UserPresence, Message } from '../lib/types';
|
||||||
|
import Avatar from './Avatar';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import ImageLightbox from './ImageLightbox';
|
||||||
|
import { getMediaUrl } from '../lib/utils';
|
||||||
|
import { getCroppedImg } from '../lib/imageCrop';
|
||||||
|
|
||||||
interface GroupSettingsProps {
|
interface GroupSettingsProps {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
@@ -31,16 +43,33 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
|
|
||||||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||||
const isAdmin = currentMember?.role === 'admin';
|
const isAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(currentMember?.role || '');
|
||||||
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||||
const [groupName, setGroupName] = useState(chat.name || '');
|
const [groupName, setGroupName] = useState(chat.name || '');
|
||||||
|
const [groupDesc, setGroupDesc] = useState(chat.description || '');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
const [showAddMember, setShowAddMember] = useState(false);
|
const [showAddMember, setShowAddMember] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'media' | 'files' | 'links'>('media');
|
||||||
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||||
|
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||||
|
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Cropping states
|
||||||
|
const [isCropping, setIsCropping] = useState(false);
|
||||||
|
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||||
|
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -48,7 +77,8 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
// Keep local state in sync with chat prop
|
// Keep local state in sync with chat prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGroupName(chat.name || '');
|
setGroupName(chat.name || '');
|
||||||
}, [chat.name]);
|
setGroupDesc(chat.description || '');
|
||||||
|
}, [chat.name, chat.description]);
|
||||||
|
|
||||||
// Search users to add
|
// Search users to add
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,6 +116,53 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDesc = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const updatedChat = await api.updateGroup(chat.id, { description: groupDesc.trim() });
|
||||||
|
updateChat(updatedChat);
|
||||||
|
setIsEditingDesc(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setCropImage(reader.result as string);
|
||||||
|
setCropFile(file);
|
||||||
|
setIsCropping(true);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCropSave = async () => {
|
||||||
|
if (!cropImage || !croppedAreaPixels) return;
|
||||||
|
setAvatarUploading(true);
|
||||||
|
try {
|
||||||
|
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||||
|
if (!croppedFile) throw new Error("Could not crop image");
|
||||||
|
|
||||||
|
const updatedChat = await api.uploadGroupAvatar(chat.id, croppedFile);
|
||||||
|
|
||||||
|
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
|
||||||
|
setIsCropping(false);
|
||||||
|
setCropImage(null);
|
||||||
|
setCropFile(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to crop group avatar:', err);
|
||||||
|
alert(t('error'));
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -146,6 +223,34 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
|
const loadTabData = async (tab: 'media' | 'files' | 'links') => {
|
||||||
|
if (loadedTabs.has(tab)) return;
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getSharedMedia(chat.id, tab);
|
||||||
|
if (tab === 'media') setSharedMedia(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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTabData(activeTab);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||||
|
...m,
|
||||||
|
url: getMediaUrl(m.url),
|
||||||
|
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||||||
|
messageId: msg.id
|
||||||
|
})));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -160,7 +265,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed right-3 top-3 bottom-3 w-[380px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||||
@@ -181,7 +286,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||||
{chat.avatar ? (
|
{chat.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={chat.avatar}
|
src={getMediaUrl(chat.avatar)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||||||
/>
|
/>
|
||||||
@@ -200,84 +305,253 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||||
>
|
>
|
||||||
{avatarUploading ? (
|
{avatarUploading ? (
|
||||||
<Loader2 size={24} className="text-white animate-spin" />
|
<Loader2 size={32} className="text-white animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Camera size={24} className="text-white" />
|
<Camera size={32} className="text-white" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{chat.avatar && (
|
{chat.avatar && !avatarUploading && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveAvatar}
|
onClick={async (e) => {
|
||||||
disabled={avatarUploading}
|
e.stopPropagation();
|
||||||
className="absolute -top-1 -right-1 w-7 h-7 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
|
try {
|
||||||
|
const updatedChat = await api.removeGroupAvatar(chat.id);
|
||||||
|
updateChat(updatedChat);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to remove avatar', e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute bottom-0 right-0 p-2 rounded-full bg-red-500/90 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
||||||
>
|
>
|
||||||
<X size={14} className="text-white" />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleAvatarUpload}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group name */}
|
<div className="mt-4 flex flex-col items-center gap-2">
|
||||||
{isEditingName ? (
|
{isEditingName ? (
|
||||||
<div className="mt-4 flex items-center gap-2 w-full max-w-[260px]">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={groupName}
|
value={groupName}
|
||||||
onChange={(e) => setGroupName(e.target.value)}
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
className="flex-1 text-lg font-bold text-center text-white bg-transparent border-b border-vortex-500 outline-none px-2 py-1"
|
className="bg-surface-tertiary border border-accent/30 rounded-xl px-4 py-2 text-lg font-bold text-white text-center focus:outline-none focus:border-accent"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === 'Enter') handleSaveName();
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setIsEditingName(false);
|
|
||||||
setGroupName(chat.name || '');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={isSaving || !groupName.trim()}
|
|
||||||
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
{isSaving ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditingName(false);
|
|
||||||
setGroupName(chat.name || '');
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg text-zinc-400 hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<h3 className="text-xl font-bold text-white">{chat.name}</h3>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditingName(true)}
|
onClick={handleSaveName}
|
||||||
className="p-1 rounded-lg text-zinc-500 hover:text-white hover:bg-surface-hover transition-colors"
|
disabled={isSaving}
|
||||||
|
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||||
>
|
>
|
||||||
<Edit3 size={14} />
|
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsEditingName(false); setGroupName(chat.name || ''); }}
|
||||||
|
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="group/name flex items-center gap-2 cursor-pointer"
|
||||||
|
onClick={() => isAdmin && setIsEditingName(true)}
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{chat.name || t('group')}
|
||||||
|
</h3>
|
||||||
|
{isAdmin && (
|
||||||
|
<Edit3 size={16} className="text-vortex-400 opacity-0 group-hover/name:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
{chat.members.length} {t('members')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mt-6 w-full space-y-2">
|
||||||
|
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">
|
||||||
|
{t('groupDescription')}
|
||||||
|
</label>
|
||||||
|
{isEditingDesc ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<textarea
|
||||||
|
value={groupDesc}
|
||||||
|
onChange={(e) => setGroupDesc(e.target.value)}
|
||||||
|
className="flex-1 bg-surface-tertiary border border-accent/30 rounded-xl px-3 py-2 text-sm text-white focus:outline-none min-h-[80px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDesc}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsEditingDesc(false); setGroupDesc(chat.description || ''); }}
|
||||||
|
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => isAdmin && setIsEditingDesc(true)}
|
||||||
|
className={`group/desc relative p-3 rounded-xl border border-white/5 bg-white/5 transition-all ${isAdmin ? 'cursor-pointer hover:bg-white/10 hover:border-white/10' : ''}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm ${groupDesc ? 'text-zinc-300' : 'text-zinc-600 italic'}`}>
|
||||||
|
{groupDesc || t('noDescription')}
|
||||||
|
</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="absolute top-3 right-3 opacity-0 group-hover/desc:opacity-100 transition-opacity">
|
||||||
|
<Edit3 size={14} className="text-vortex-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-sm text-zinc-400 mt-1 flex items-center gap-1">
|
|
||||||
<Users size={14} />
|
</div>
|
||||||
{chat.members.length} {t('members')}
|
|
||||||
</p>
|
|
||||||
|
{/* Media / Files / Links Tabs */}
|
||||||
|
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl">
|
||||||
|
<div className="flex border-b border-white/5">
|
||||||
|
{[
|
||||||
|
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||||
|
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||||
|
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-white/5 text-vortex-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={16} />
|
||||||
|
<span className="truncate w-full px-1">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[200px] max-h-[300px] overflow-y-auto">
|
||||||
|
{tabLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : activeTab === 'media' ? (
|
||||||
|
allMedia.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||||
|
{allMedia.map((m, idx) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setLightboxIndex(idx)}
|
||||||
|
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||||
|
>
|
||||||
|
{m.type === 'video' ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||||
|
onClick={() => setLightboxIndex(idx)}
|
||||||
|
>
|
||||||
|
{m.thumbnail ? (
|
||||||
|
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||||
|
<Video size={32} className="text-white/20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||||
|
<Play size={24} className="text-white fill-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={getMediaUrl(m.url)}
|
||||||
|
alt=""
|
||||||
|
onClick={() => setLightboxIndex(idx)}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||||
|
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : activeTab === 'files' ? (
|
||||||
|
sharedFiles.length > 0 ? (
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{sharedFiles.flatMap((msg) =>
|
||||||
|
(msg.media || []).map((m) => (
|
||||||
|
<div key={m.id} className="relative group/file">
|
||||||
|
<a
|
||||||
|
href={getMediaUrl(m.url)}
|
||||||
|
download={m.filename || 'file'}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-vortex-500/10 flex items-center justify-center flex-shrink-0 text-vortex-400">
|
||||||
|
<FileText size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||||||
|
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||||||
|
</div>
|
||||||
|
<Download size={14} className="text-zinc-600" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||||
|
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
sharedLinks.length > 0 ? (
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{sharedLinks.map((msg) => (
|
||||||
|
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors">
|
||||||
|
{msg.links?.map((link, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-[13px] text-vortex-400 hover:underline truncate mb-1"
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} className="flex-shrink-0" />
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||||
|
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members */}
|
||||||
@@ -334,7 +608,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||||
>
|
>
|
||||||
{u.avatar ? (
|
{u.avatar ? (
|
||||||
<img src={u.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||||
@@ -356,10 +630,14 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
|
|
||||||
{/* Member list */}
|
{/* Member list */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{chat.members
|
{[...chat.members]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.role === 'admin' && b.role !== 'admin') return -1;
|
if (a.user.id === user?.id) return -1;
|
||||||
if (b.role === 'admin' && a.role !== 'admin') return 1;
|
if (b.user.id === user?.id) return 1;
|
||||||
|
const aIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(a.role || '');
|
||||||
|
const bIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(b.role || '');
|
||||||
|
if (aIsAdmin && !bIsAdmin) return -1;
|
||||||
|
if (bIsAdmin && !aIsAdmin) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
.map((member) => (
|
.map((member) => (
|
||||||
@@ -369,7 +647,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
{member.user.avatar ? (
|
{member.user.avatar ? (
|
||||||
<img src={member.user.avatar} alt="" className="w-9 h-9 rounded-full object-cover" />
|
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||||
@@ -387,16 +665,16 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{member.role === 'admin' && (
|
{['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
||||||
<Crown size={10} />
|
<Crown size={10} />
|
||||||
{t('adminBadge')}
|
{t('adminBadge') || 'Админ'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && member.user.id !== user?.id && member.role !== 'admin' && (
|
{isAdmin && member.user.id !== user?.id && !['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveMember(member.user.id)}
|
onClick={() => handleRemoveMember(member.user.id)}
|
||||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
||||||
@@ -418,6 +696,92 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
|||||||
onConfirm={confirmRemoveMember}
|
onConfirm={confirmRemoveMember}
|
||||||
onCancel={() => setRemoveTargetId(null)}
|
onCancel={() => setRemoveTargetId(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<ImageLightbox
|
||||||
|
images={sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||||
|
url: getMediaUrl(m.url),
|
||||||
|
type: m.type
|
||||||
|
})))}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCropping && cropImage && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
|
||||||
|
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||||
|
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
|
||||||
|
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-80 bg-black">
|
||||||
|
<Cropper
|
||||||
|
image={cropImage}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={1}
|
||||||
|
cropShape="round"
|
||||||
|
showGrid={false}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={zoom}
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-vortex-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCropping(false)}
|
||||||
|
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCropSave}
|
||||||
|
disabled={avatarUploading}
|
||||||
|
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{avatarUploading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||||
|
{t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useAuthStore } from '../stores/authStore';
|
|||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { extractWaveform } from '../lib/utils';
|
import { extractWaveform, getMediaUrl } from '../lib/utils';
|
||||||
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
||||||
import ImageLightbox from './ImageLightbox';
|
import ImageLightbox from './ImageLightbox';
|
||||||
|
|
||||||
@@ -434,11 +434,11 @@ function MessageBubble({
|
|||||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
|
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
|
||||||
{message.storyMediaType === 'video' ? (
|
{message.storyMediaType === 'video' ? (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<video src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" />
|
<video src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
|
||||||
</div>
|
</div>
|
||||||
) : message.storyMediaType === 'image' ? (
|
) : message.storyMediaType === 'image' ? (
|
||||||
<img src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" alt="" />
|
<img src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" alt="" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-vortex-500/20"><FileText size={10} className="text-vortex-400" /></div>
|
<div className="w-full h-full flex items-center justify-center bg-vortex-500/20"><FileText size={10} className="text-vortex-400" /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ type SideView = 'main' | 'profile' | 'settings' | 'about' | 'themes' | 'friends'
|
|||||||
interface SideMenuProps {
|
interface SideMenuProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onOpenProfile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
|
||||||
const { user, updateUser, logout } = useAuthStore();
|
const { user, updateUser, logout } = useAuthStore();
|
||||||
const { clearStore } = useChatStore();
|
const { clearStore } = useChatStore();
|
||||||
const { chatTheme, setChatTheme } = useThemeStore();
|
const { chatTheme, setChatTheme } = useThemeStore();
|
||||||
@@ -53,12 +54,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
|
|
||||||
const [view, setView] = useState<SideView>('main');
|
const [view, setView] = useState<SideView>('main');
|
||||||
const [prevView, setPrevView] = useState<SideView>('main');
|
const [prevView, setPrevView] = useState<SideView>('main');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [bio, setBio] = useState('');
|
|
||||||
const [birthday, setBirthday] = useState('');
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
||||||
const [themeIndex, setThemeIndex] = useState(0);
|
const [themeIndex, setThemeIndex] = useState(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -191,7 +186,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
||||||
setIsEditing(false);
|
|
||||||
setFriendSearch('');
|
setFriendSearch('');
|
||||||
setFriendSearchResults([]);
|
setFriendSearchResults([]);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -234,63 +228,12 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setDisplayName(user.displayName || '');
|
|
||||||
setBio(user.bio || '');
|
|
||||||
setBirthday(user.birthday || '');
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearStore();
|
clearStore();
|
||||||
logout();
|
logout();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
const updated = await api.updateProfile({
|
|
||||||
displayName: displayName.trim(),
|
|
||||||
bio: bio.trim(),
|
|
||||||
birthday: birthday || undefined,
|
|
||||||
});
|
|
||||||
updateUser(updated);
|
|
||||||
setIsEditing(false);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
setAvatarUploading(true);
|
|
||||||
const updated = await api.uploadAvatar(file);
|
|
||||||
updateUser(updated);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setAvatarUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAvatar = async () => {
|
|
||||||
try {
|
|
||||||
setAvatarUploading(true);
|
|
||||||
await api.removeAvatar();
|
|
||||||
updateUser({ avatar: null });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setAvatarUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initials = (user?.displayName || user?.username || '??')
|
const initials = (user?.displayName || user?.username || '??')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((w: string) => w[0])
|
.map((w: string) => w[0])
|
||||||
@@ -299,8 +242,9 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: User, label: t('myProfile'), onClick: () => changeView('profile') },
|
{ icon: User, label: t('myProfile'), onClick: () => { onClose(); onOpenProfile(); } },
|
||||||
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
||||||
|
|
||||||
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ icon: Info, label: t('aboutApp'), subtitle: 'SelfHost Messenger v1.0', onClick: () => changeView('about') },
|
{ icon: Info, label: t('aboutApp'), subtitle: 'SelfHost Messenger v1.0', onClick: () => changeView('about') },
|
||||||
@@ -327,7 +271,7 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
<div className="relative p-6 pb-5">
|
<div className="relative p-6 pb-5">
|
||||||
<div className="flex items-start justify-between mb-5">
|
<div className="flex items-start justify-between mb-5">
|
||||||
{/* Avatar with glow ring */}
|
{/* Avatar with glow ring */}
|
||||||
<div className="relative group cursor-pointer" onClick={() => changeView('profile')}>
|
<div className="relative group cursor-pointer" onClick={() => { onClose(); onOpenProfile(); }}>
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-60 blur group-hover:opacity-90 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-60 blur group-hover:opacity-90 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{user?.avatar ? (
|
{user?.avatar ? (
|
||||||
@@ -405,187 +349,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ======= PROFILE VIEW =======
|
|
||||||
const renderProfile = () => (
|
|
||||||
<motion.div key="profile" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden flex-shrink-0">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
|
||||||
<div className="flex items-center gap-3 relative z-10">
|
|
||||||
<button onClick={() => { changeView('main'); setIsEditing(false); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</button>
|
|
||||||
<h3 className="text-lg font-bold tracking-tight text-white drop-shadow-sm">{t('myProfile')}</h3>
|
|
||||||
</div>
|
|
||||||
{!isEditing ? (
|
|
||||||
<button onClick={() => setIsEditing(true)} className="relative z-10 p-2 rounded-full text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5">
|
|
||||||
<Edit3 size={16} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={handleSave} disabled={isSaving} className="relative z-10 p-2 rounded-full text-vortex-400 hover:text-vortex-300 hover:bg-vortex-500/10 transition-all border border-vortex-500/20">
|
|
||||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{/* Avatar section */}
|
|
||||||
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="relative group">
|
|
||||||
{/* Spinning gradient glow ring */}
|
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-50 blur group-hover:opacity-75 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{user?.avatar ? (
|
|
||||||
<img src={user.avatar} alt="" className="w-28 h-28 rounded-full object-cover ring-4 ring-surface bg-surface" />
|
|
||||||
) : (
|
|
||||||
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center text-white font-bold text-3xl ring-4 ring-surface relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
|
|
||||||
<span className="relative z-10 text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload overlay */}
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={avatarUploading}
|
|
||||||
className="absolute inset-x-1 bottom-1 h-9 rounded-full bg-black/60 backdrop-blur-md border border-white/10 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-1.5 text-xs font-medium text-white transition-all transform translate-y-2 group-hover:translate-y-0"
|
|
||||||
>
|
|
||||||
{avatarUploading ? (
|
|
||||||
<Loader2 size={14} className="text-vortex-400 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Camera size={14} className="text-vortex-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Remove avatar button */}
|
|
||||||
{user?.avatar && (
|
|
||||||
<button
|
|
||||||
onClick={handleRemoveAvatar}
|
|
||||||
disabled={avatarUploading}
|
|
||||||
className="absolute h-7 px-2.5 -top-1 left-1/2 -translate-x-1/2 bg-red-500/80 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)] border border-red-400/30 transform -translate-y-2 group-hover:translate-y-0"
|
|
||||||
>
|
|
||||||
<Trash2 size={10} className="text-white" />
|
|
||||||
<span className="text-[10px] font-semibold text-white">{t('removePhoto')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="mt-5 w-full max-w-[260px] relative">
|
|
||||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-vortex-500 to-purple-500 rounded-2xl opacity-50 blur-sm pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => 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-vortex-400 placeholder-white/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<h3 className="mt-4 text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
|
||||||
{user?.displayName || user?.username}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Username badge */}
|
|
||||||
<div className="flex items-center gap-1.5 mt-2 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-3.5 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
|
||||||
<AtSign size={13} className="text-vortex-400" />
|
|
||||||
<span className="text-sm font-semibold text-vortex-100">{user?.username}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info cards */}
|
|
||||||
<div className="px-4 space-y-2.5 pb-6">
|
|
||||||
{/* About */}
|
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-vortex-500/20 flex items-center justify-center border border-vortex-500/30">
|
|
||||||
<Edit3 size={12} className="text-vortex-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-vortex-200/50 uppercase tracking-widest">{t('aboutMe')}</span>
|
|
||||||
</div>
|
|
||||||
{isEditing ? (
|
|
||||||
<textarea
|
|
||||||
value={bio}
|
|
||||||
onChange={(e) => setBio(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-xl bg-black/40 text-sm text-white placeholder-white/30 p-3 border border-white/10 focus:border-vortex-500 transition-colors resize-none outline-none leading-relaxed"
|
|
||||||
placeholder={t('tellAboutYourself')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
|
||||||
{user?.bio || <span className="text-white/30 italic">{t('notSpecified')}</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Birthday */}
|
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
|
|
||||||
<Calendar size={12} className="text-orange-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-orange-200/50 uppercase tracking-widest">{t('birthday')}</span>
|
|
||||||
</div>
|
|
||||||
{isEditing ? (
|
|
||||||
<DatePicker value={birthday} onChange={setBirthday} />
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-200 pl-1">
|
|
||||||
{user?.birthday ? (
|
|
||||||
new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
) : (
|
|
||||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Member since */}
|
|
||||||
{user?.createdAt && (
|
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
|
|
||||||
<Check size={12} className="text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-emerald-200/50 uppercase tracking-widest">{t('onVortexSince')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-zinc-200 pl-1">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
{isEditing && (
|
|
||||||
<div className="px-4 pb-6 flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => { setIsEditing(false); setDisplayName(user?.displayName || ''); setBio(user?.bio || ''); setBirthday(user?.birthday || ''); }}
|
|
||||||
className="flex-1 py-3 rounded-xl bg-black/20 hover:bg-black/40 border border-white/5 text-sm font-semibold text-zinc-300 hover:text-white transition-all backdrop-blur-md"
|
|
||||||
>
|
|
||||||
{t('cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 hover:from-vortex-600 hover:to-purple-700 text-sm font-bold text-white transition-all shadow-[0_0_20px_rgba(168,85,247,0.4)] flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
|
||||||
{t('save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ======= SETTINGS VIEW =======
|
// ======= SETTINGS VIEW =======
|
||||||
const renderSettings = () => (
|
const renderSettings = () => (
|
||||||
<motion.div key="settings" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
<motion.div key="settings" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
@@ -988,7 +751,6 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
|||||||
>
|
>
|
||||||
<AnimatePresence mode="wait" custom={slideDir}>
|
<AnimatePresence mode="wait" custom={slideDir}>
|
||||||
{view === 'main' && renderMain()}
|
{view === 'main' && renderMain()}
|
||||||
{view === 'profile' && renderProfile()}
|
|
||||||
{view === 'settings' && renderSettings()}
|
{view === 'settings' && renderSettings()}
|
||||||
{view === 'themes' && renderThemes()}
|
{view === 'themes' && renderThemes()}
|
||||||
{view === 'friends' && renderFriends()}
|
{view === 'friends' && renderFriends()}
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export default function Sidebar() {
|
|||||||
<SideMenu
|
<SideMenu
|
||||||
isOpen={showSideMenu}
|
isOpen={showSideMenu}
|
||||||
onClose={() => setShowSideMenu(false)}
|
onClose={() => setShowSideMenu(false)}
|
||||||
|
onOpenProfile={() => setShowProfile(true)}
|
||||||
/>
|
/>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getSocket } from '../lib/socket';
|
|||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import { StoryGroup } from '../lib/types';
|
import { StoryGroup } from '../lib/types';
|
||||||
|
import { getMediaUrl } from '../lib/utils';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
@@ -64,20 +65,18 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
// Calculate isVideo before using it in effects
|
// Calculate isVideo before using it in effects
|
||||||
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
|
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
|
||||||
|
|
||||||
// Pause when showing reactions or reply input
|
// Pause when showing reactions or reply input or state changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showReactions || showReplyInput || showViewers) {
|
if (showReactions || showReplyInput || showViewers || paused) {
|
||||||
setPaused(true);
|
|
||||||
if (videoRef.current && !videoRef.current.paused) {
|
if (videoRef.current && !videoRef.current.paused) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setPaused(false);
|
|
||||||
if (videoRef.current && videoRef.current.paused && isVideo) {
|
if (videoRef.current && videoRef.current.paused && isVideo) {
|
||||||
videoRef.current.play().catch(() => { });
|
videoRef.current.play().catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showReactions, showReplyInput, showViewers, isVideo]);
|
}, [showReactions, showReplyInput, showViewers, paused, isVideo]);
|
||||||
|
|
||||||
// Handle video play/pause sync with paused state
|
// Handle video play/pause sync with paused state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,8 +162,11 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
}, [storyIndex, userIndex]);
|
}, [storyIndex, userIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused || !currentStory) return;
|
if (paused || !currentStory || isVideo) return;
|
||||||
const step = (TICK / STORY_DURATION) * 100;
|
|
||||||
|
const duration = STORY_DURATION;
|
||||||
|
const step = (TICK / duration) * 100;
|
||||||
|
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setProgress(prev => {
|
setProgress(prev => {
|
||||||
if (prev >= 100) {
|
if (prev >= 100) {
|
||||||
@@ -178,7 +180,22 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [storyIndex, userIndex, paused, goNext]);
|
}, [storyIndex, userIndex, paused, goNext, isVideo, currentStory]);
|
||||||
|
|
||||||
|
// Handle video progress
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !isVideo || paused) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (video.duration) {
|
||||||
|
const p = (video.currentTime / video.duration) * 100;
|
||||||
|
setProgress(p);
|
||||||
|
}
|
||||||
|
}, TICK);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isVideo, paused, storyIndex, userIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -325,7 +342,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
};
|
};
|
||||||
|
|
||||||
const avatarUrl = currentUser.user.avatar
|
const avatarUrl = currentUser.user.avatar
|
||||||
? `${API_URL}${currentUser.user.avatar}`
|
? getMediaUrl(currentUser.user.avatar)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -345,7 +362,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
src={getMediaUrl(currentStory.mediaUrl)}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
autoPlay
|
autoPlay
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
@@ -356,7 +373,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
src={getMediaUrl(currentStory.mediaUrl)}
|
||||||
alt="story"
|
alt="story"
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -435,10 +452,17 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute inset-0 flex z-[5]">
|
<div
|
||||||
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
|
className="absolute inset-0 flex z-[5]"
|
||||||
|
onMouseDown={() => setPaused(true)}
|
||||||
|
onMouseUp={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||||
|
onMouseLeave={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||||
|
onTouchStart={() => setPaused(true)}
|
||||||
|
onTouchEnd={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goPrev(); }} />
|
||||||
<div className="w-1/3 h-full" />
|
<div className="w-1/3 h-full" />
|
||||||
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
|
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goNext(); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canGoPrev && (
|
{canGoPrev && (
|
||||||
@@ -574,7 +598,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
{viewers.map((v) => (
|
{viewers.map((v) => (
|
||||||
<div key={v.userId} className="flex items-center gap-3 py-1.5">
|
<div key={v.userId} className="flex items-center gap-3 py-1.5">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={v.avatar ? `${API_URL}${v.avatar}` : null}
|
src={v.avatar ? getMediaUrl(v.avatar) : null}
|
||||||
name={v.displayName || v.username}
|
name={v.displayName || v.username}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 } from 'lucide-react';
|
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 } from 'lucide-react';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
|
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
import ImageLightbox from './ImageLightbox';
|
import ImageLightbox from './ImageLightbox';
|
||||||
|
import StoryViewer from './StoryViewer';
|
||||||
import { getSocket } from '../lib/socket';
|
import { getSocket } from '../lib/socket';
|
||||||
import { useStoryStore } from '../stores/useStoryStore';
|
import { useStoryStore } from '../stores/useStoryStore';
|
||||||
|
import { getMediaUrl } from '../lib/utils';
|
||||||
|
import { getCroppedImg } from '../lib/imageCrop';
|
||||||
|
import DatePicker from './DatePicker';
|
||||||
|
|
||||||
interface UserProfileProps {
|
interface UserProfileProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -17,39 +23,51 @@ interface UserProfileProps {
|
|||||||
isSelf?: boolean;
|
isSelf?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaTab = 'stories' | 'media' | 'files' | 'links';
|
type MediaTab = 'publications' | 'media' | 'files' | 'links';
|
||||||
|
|
||||||
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
|
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
|
||||||
const { user: authUser } = useAuthStore();
|
const { user: authUser } = useAuthStore();
|
||||||
const { t, lang } = useLang();
|
const { t, lang } = useLang();
|
||||||
const [profile, setProfile] = useState<User | null>(null);
|
const [profile, setProfile] = useState<User | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<MediaTab>('media');
|
const [activeTab, setActiveTab] = useState<MediaTab>('publications');
|
||||||
|
|
||||||
// Shared media state
|
// Shared media state
|
||||||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||||
const [tabLoading, setTabLoading] = useState(false);
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
const [userStories, setUserStories] = useState<StoryGroup | null>(null);
|
const [userStories, setUserStories] = useState<any[]>([]); // Changed from StoryGroup | null to any[] as per instruction
|
||||||
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
|
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const { openViewer } = useStoryStore();
|
const { openViewer } = useStoryStore();
|
||||||
|
const [storyViewerOpen, setStoryViewerOpen] = useState(false);
|
||||||
|
const [initialStoryIdx, setInitialStoryIdx] = useState(0);
|
||||||
|
|
||||||
// Friend state
|
// Friend state
|
||||||
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
||||||
const [friendLoading, setFriendLoading] = useState(false);
|
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
|
// Avatar edit state
|
||||||
const [isEditingAvatar, setIsEditingAvatar] = useState(false);
|
|
||||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
|
||||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
|
||||||
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
|
|
||||||
const [isCropping, setIsCropping] = useState(false);
|
const [isCropping, setIsCropping] = useState(false);
|
||||||
|
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||||
|
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||||
...m,
|
...m,
|
||||||
url: m.url.startsWith('http') ? m.url : `${import.meta.env.VITE_API_URL}${m.url}`,
|
url: getMediaUrl(m.url),
|
||||||
messageId: msg.id
|
messageId: msg.id
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -58,32 +76,22 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, isSelf]);
|
||||||
|
|
||||||
// Load shared media/files/links when tab changes
|
// Load shared media/files/links when tab changes
|
||||||
const loadTabData = useCallback(async (tab: MediaTab) => {
|
const loadTabData = useCallback(async (tab: MediaTab) => {
|
||||||
if (tab === 'stories') {
|
if (loadedTabs.has(tab)) return;
|
||||||
if (loadedTabs.has(tab)) return;
|
|
||||||
setTabLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await api.getUserStories(userId);
|
|
||||||
setUserStories(data);
|
|
||||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load user stories', e);
|
|
||||||
} finally {
|
|
||||||
setTabLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chatId || loadedTabs.has(tab)) return;
|
|
||||||
setTabLoading(true);
|
setTabLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getSharedMedia(chatId, tab);
|
if (tab === 'publications') {
|
||||||
if (tab === 'media') setSharedMedia(data);
|
const data = await api.getUserStories(userId);
|
||||||
else if (tab === 'files') setSharedFiles(data);
|
setUserStories(data.stories || []);
|
||||||
else setSharedLinks(data);
|
} else if (chatId) { // Only load media/files/links if chatId is available
|
||||||
|
const data = await api.getSharedMedia(chatId, tab);
|
||||||
|
if (tab === 'media') setSharedMedia(data);
|
||||||
|
else if (tab === 'files') setSharedFiles(data);
|
||||||
|
else setSharedLinks(data);
|
||||||
|
}
|
||||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load shared', tab, e);
|
console.error('Failed to load shared', tab, e);
|
||||||
@@ -101,9 +109,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (isSelf && authUser) {
|
if (isSelf && authUser) {
|
||||||
setProfile(authUser);
|
setProfile(authUser);
|
||||||
|
setDisplayName(authUser.displayName || '');
|
||||||
|
setBio(authUser.bio || '');
|
||||||
|
setBirthday(authUser.birthday || '');
|
||||||
} else {
|
} else {
|
||||||
const data = await api.getUser(userId);
|
const data = await api.getUser(userId);
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
|
if (isSelf) {
|
||||||
|
setDisplayName(data.displayName || '');
|
||||||
|
setBio(data.bio || '');
|
||||||
|
setBirthday(data.birthday || '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -112,6 +128,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const dateToSave = birthday ? new Date(birthday).toISOString() : undefined;
|
||||||
|
const updated = await api.updateProfile({
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
bio: bio.trim(),
|
||||||
|
birthday: dateToSave,
|
||||||
|
});
|
||||||
|
setProfile(updated);
|
||||||
|
useAuthStore.getState().updateUser(updated);
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSendFriendRequest = async () => {
|
const handleSendFriendRequest = async () => {
|
||||||
try {
|
try {
|
||||||
setFriendLoading(true);
|
setFriendLoading(true);
|
||||||
@@ -172,25 +207,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCropSave = async () => {
|
const handleCropSave = async () => {
|
||||||
if (!cropFile || !cropImage) return;
|
if (!cropImage || !croppedAreaPixels) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTabLoading(true);
|
setTabLoading(true); // Reusing tabLoading for avatar upload
|
||||||
// For now, we'll just send the file and the crop data as separate fields
|
|
||||||
// In a real app, we might crop on the client via canvas
|
|
||||||
const cropData = {
|
|
||||||
x: Math.round(cropPosition.x),
|
|
||||||
y: Math.round(cropPosition.y),
|
|
||||||
width: 400, // Fixed size for simplicity
|
|
||||||
height: 400
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedUser = await api.cropAvatar(cropFile, cropData);
|
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
|
||||||
|
if (!croppedFile) throw new Error("Could not crop image");
|
||||||
|
|
||||||
|
const updatedUser = await api.uploadAvatar(croppedFile);
|
||||||
setProfile(updatedUser);
|
setProfile(updatedUser);
|
||||||
useAuthStore.getState().updateUser(updatedUser);
|
useAuthStore.getState().updateUser(updatedUser);
|
||||||
setIsCropping(false);
|
setIsCropping(false);
|
||||||
setCropImage(null);
|
setCropImage(null);
|
||||||
setCropFile(null);
|
setCropFile(null);
|
||||||
|
setCroppedAreaPixels(null);
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
setZoom(1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save avatar', e);
|
console.error('Failed to save avatar', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -198,6 +231,20 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = async () => {
|
||||||
|
try {
|
||||||
|
setTabLoading(true);
|
||||||
|
await api.removeAvatar();
|
||||||
|
const updatedUser = { ...profile!, avatar: null };
|
||||||
|
setProfile(updatedUser);
|
||||||
|
useAuthStore.getState().updateUser({ avatar: null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setTabLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initials = (profile?.displayName || profile?.username || '??')
|
const initials = (profile?.displayName || profile?.username || '??')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((w: string) => w[0])
|
.map((w: string) => w[0])
|
||||||
@@ -206,7 +253,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ key: 'stories', label: (t('storiesTab') || 'Stories') as string, icon: ImageIcon },
|
{ key: 'publications', label: (t('publicationsTab') || 'Публикации') as string, icon: Play },
|
||||||
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
|
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
|
||||||
{ key: 'files', label: t('filesTab') as string, icon: FileText },
|
{ key: 'files', label: t('filesTab') as string, icon: FileText },
|
||||||
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
|
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
|
||||||
@@ -222,24 +269,44 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
initial={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
|
||||||
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
||||||
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
exit={{ opacity: 0, x: 50, filter: 'blur(10px)' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed right-3 top-3 bottom-3 w-[500px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
|
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||||
>
|
>
|
||||||
{/* Шапка */}
|
{/* Шапка */}
|
||||||
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
|
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
||||||
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
|
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10 flex-1">
|
||||||
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
|
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<div className="flex items-center gap-2 relative z-10">
|
||||||
onClick={onClose}
|
{isSelf && (
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5 relative z-10"
|
!isEditing ? (
|
||||||
>
|
<button
|
||||||
<X size={18} />
|
onClick={() => setIsEditing(true)}
|
||||||
</button>
|
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5"
|
||||||
|
>
|
||||||
|
<Edit3 size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-full bg-vortex-500/20 text-vortex-400 hover:text-vortex-300 hover:bg-vortex-500/30 transition-all border border-vortex-500/30"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -259,7 +326,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{profile.avatar ? (
|
{profile.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={profile.avatar}
|
src={getMediaUrl(profile.avatar)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-32 h-32 rounded-full object-cover ring-4 ring-surface bg-surface"
|
className="w-32 h-32 rounded-full object-cover ring-4 ring-surface bg-surface"
|
||||||
/>
|
/>
|
||||||
@@ -279,23 +346,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<label className="absolute bottom-0 right-0 w-10 h-10 bg-accent hover:bg-accent-light text-white rounded-full border-4 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110 z-20">
|
<div className="absolute top-0 right-0 p-1.5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||||
<Edit3 size={18} />
|
<label className="w-9 h-9 bg-accent hover:bg-accent-light text-white rounded-full border-2 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110">
|
||||||
<input
|
<Camera size={16} />
|
||||||
type="file"
|
<input
|
||||||
className="hidden"
|
type="file"
|
||||||
accept="image/*,video/*"
|
className="hidden"
|
||||||
onChange={handleAvatarSelect}
|
accept="image/*"
|
||||||
/>
|
onChange={handleAvatarSelect}
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSelf && profile.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="absolute h-9 px-3 -bottom-2 left-1/2 -translate-x-1/2 bg-red-500/90 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)] border border-red-400/30 transform translate-y-2 group-hover:translate-y-0 z-30"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} className="text-white" />
|
||||||
|
<span className="text-xs font-semibold text-white">{t('removePhoto')}</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Имя */}
|
{/* Имя */}
|
||||||
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
{isEditing ? (
|
||||||
{profile.displayName || profile.username}
|
<div className="mt-5 w-full max-w-[260px] relative">
|
||||||
</h3>
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-vortex-500 to-purple-500 rounded-2xl opacity-50 blur-sm pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => 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-vortex-400 placeholder-white/30 truncate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
||||||
|
{profile.displayName || profile.username}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username (неизменяемый) */}
|
{/* Username (неизменяемый) */}
|
||||||
<div className="flex items-center gap-1.5 mt-2.5 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-4 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
<div className="flex items-center gap-1.5 mt-2.5 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-4 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
||||||
@@ -382,15 +474,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
{t('aboutMe')}
|
{t('aboutMe')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
{isEditing ? (
|
||||||
{profile.bio || (
|
<textarea
|
||||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
value={bio}
|
||||||
)}
|
onChange={(e) => setBio(e.target.value)}
|
||||||
</p>
|
rows={3}
|
||||||
|
className="w-full rounded-xl bg-black/40 text-sm text-white placeholder-white/30 p-3 border border-white/10 focus:border-vortex-500 transition-colors resize-none outline-none leading-relaxed"
|
||||||
|
placeholder={t('tellAboutYourself')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
||||||
|
{profile.bio || (
|
||||||
|
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дата рождения */}
|
{/* Дата рождения */}
|
||||||
{profile.birthday && (
|
{(profile.birthday || isEditing) && (
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
|
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
|
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
|
||||||
@@ -400,17 +502,21 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
{t('birthday')}
|
{t('birthday')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-200 pl-1">
|
{isEditing ? (
|
||||||
{profile.birthday ? (
|
<DatePicker value={birthday} onChange={setBirthday} />
|
||||||
new Date(profile.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
) : (
|
||||||
day: 'numeric',
|
<p className="text-sm text-zinc-200 pl-1">
|
||||||
month: 'long',
|
{profile.birthday ? (
|
||||||
year: 'numeric',
|
new Date(profile.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
})
|
day: 'numeric',
|
||||||
) : (
|
month: 'long',
|
||||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
year: 'numeric',
|
||||||
)}
|
})
|
||||||
</p>
|
) : (
|
||||||
|
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -436,72 +542,74 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
|
|
||||||
{/* Медиа / Файлы / Ссылки */}
|
{/* Медиа / Файлы / Ссылки */}
|
||||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||||
<div className="flex px-2 pt-2 gap-1 overflow-x-auto no-scrollbar">
|
<div className="flex border-b border-white/5 h-14">
|
||||||
{tabs.map((tab) => (
|
{[
|
||||||
|
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play },
|
||||||
|
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||||
|
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||||
|
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||||
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-1 text-xs font-bold transition-all rounded-t-xl min-w-[100px] ${activeTab === tab.key
|
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||||
? 'bg-white/10 text-white shadow-[inset_0_2px_10px_rgba(255,255,255,0.05)] border-t border-x border-white/10'
|
activeTab === tab.key
|
||||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
|
? 'bg-white/5 text-vortex-400'
|
||||||
}`}
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon size={14} className={activeTab === tab.key ? 'text-vortex-400' : 'opacity-70'} />
|
<tab.icon size={16} />
|
||||||
{tab.label}
|
<span className="truncate w-full px-1">{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[160px] bg-white/[0.02] border-t border-white/5 relative">
|
|
||||||
{/* Subtle top glow for active tab content */}
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-vortex-500/50 to-transparent" />
|
|
||||||
{tabLoading ? (
|
{tabLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-10 text-zinc-500">
|
||||||
<Loader2 size={20} className="animate-spin text-zinc-500" />
|
<div className="w-6 h-6 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'stories' ? (
|
) : activeTab === 'publications' ? (
|
||||||
userStories && userStories.stories.length > 0 ? (
|
userStories.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||||
{userStories.stories.map((story, idx) => (
|
{userStories.map((s, idx) => (
|
||||||
<div
|
<div
|
||||||
key={story.id}
|
key={s.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const group: StoryGroup = {
|
setInitialStoryIdx(idx);
|
||||||
user: profile!,
|
setStoryViewerOpen(true);
|
||||||
stories: userStories.stories,
|
|
||||||
hasUnviewed: false
|
|
||||||
};
|
|
||||||
openViewer(0, idx, [group]);
|
|
||||||
}}
|
}}
|
||||||
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden group border border-white/5 rounded-md cursor-pointer"
|
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden cursor-pointer group rounded-sm"
|
||||||
>
|
>
|
||||||
{story.type === 'video' ? (
|
{s.type === 'video' ? (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
{story.mediaUrl && <video src={story.mediaUrl.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`} className="w-full h-full object-cover opacity-60" />}
|
{s.mediaUrl && <video key={s.id} src={getMediaUrl(s.mediaUrl)} className="w-full h-full object-cover opacity-60" />}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
<Play size={20} className="text-white fill-white" />
|
<Play size={24} className="text-white fill-white opacity-80" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : story.type === 'image' ? (
|
) : s.type === 'image' && s.mediaUrl ? (
|
||||||
<img
|
<img
|
||||||
src={story.mediaUrl?.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`}
|
key={s.id}
|
||||||
|
src={getMediaUrl(s.mediaUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover opacity-80"
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 opacity-80"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center p-2 text-center overflow-hidden" style={{ background: story.bgColor || 'var(--vortex-500)' }}>
|
<div className="w-full h-full p-2 flex items-center justify-center text-center overflow-hidden" style={{ background: s.bgColor || '#6366f1' }}>
|
||||||
<p className="text-[10px] text-white line-clamp-4 font-bold">{story.content}</p>
|
<p className="text-[10px] font-bold text-white line-clamp-4">{s.content}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-1 right-1 bg-black/50 backdrop-blur-sm px-1 rounded flex items-center gap-0.5">
|
<div className="absolute bottom-1 right-1 bg-black/40 backdrop-blur-sm px-1.5 rounded flex items-center gap-0.5 scale-75 origin-bottom-right">
|
||||||
<Clock size={8} className="text-zinc-300" />
|
<Eye size={10} className="text-white/70" />
|
||||||
<span className="text-[8px] text-zinc-300">{new Date(story.createdAt).toLocaleDateString()}</span>
|
<span className="text-[10px] text-white font-medium">{s.viewCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||||
<p className="text-xs text-zinc-600 italic">{(t('noStories') || 'No stories yet') as string}</p>
|
<p className="text-xs text-zinc-500 italic">Нет публикаций</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : activeTab === 'media' ? (
|
) : activeTab === 'media' ? (
|
||||||
@@ -514,19 +622,25 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
>
|
>
|
||||||
{m.type === 'video' ? (
|
{m.type === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<div
|
||||||
src={m.thumbnail ? (m.thumbnail.startsWith('http') ? m.thumbnail : `${import.meta.env.VITE_API_URL}${m.thumbnail}`) : m.url}
|
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||||
alt=""
|
|
||||||
onClick={() => setLightboxIndex(idx)}
|
onClick={() => setLightboxIndex(idx)}
|
||||||
className="w-full h-full object-cover"
|
>
|
||||||
/>
|
{m.thumbnail ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
|
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||||
<Play size={24} className="text-white fill-white" />
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||||
|
<Video size={32} className="text-white/20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||||
|
<Play size={24} className="text-white fill-white" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={m.url}
|
src={getMediaUrl(m.url)}
|
||||||
alt=""
|
alt=""
|
||||||
onClick={() => setLightboxIndex(idx)}
|
onClick={() => setLightboxIndex(idx)}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
@@ -555,7 +669,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
(msg.media || []).map((m) => (
|
(msg.media || []).map((m) => (
|
||||||
<div key={m.id} className="relative group/file">
|
<div key={m.id} className="relative group/file">
|
||||||
<a
|
<a
|
||||||
href={m.url}
|
href={getMediaUrl(m.url)}
|
||||||
download={m.filename || 'file'}
|
download={m.filename || 'file'}
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -640,13 +754,39 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
images={allMedia.map((m) => ({ url: m.url, type: m.type }))}
|
images={allMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{storyViewerOpen && profile && (
|
||||||
|
<StoryViewer
|
||||||
|
stories={[{
|
||||||
|
user: {
|
||||||
|
id: profile.id,
|
||||||
|
username: profile.username,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatar: profile.avatar
|
||||||
|
},
|
||||||
|
stories: userStories,
|
||||||
|
hasUnviewed: false
|
||||||
|
}]}
|
||||||
|
initialUserIndex={0}
|
||||||
|
initialStoryIndex={initialStoryIdx}
|
||||||
|
onClose={() => setStoryViewerOpen(false)}
|
||||||
|
onRefresh={() => {
|
||||||
|
setLoadedTabs(prev => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete('publications');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
loadTabData('publications');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Avatar Cropper Modal */}
|
{/* Avatar Cropper Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isCropping && cropImage && (
|
{isCropping && cropImage && (
|
||||||
@@ -664,37 +804,46 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 flex flex-col items-center">
|
<div className="relative w-full h-80 bg-black">
|
||||||
<div className="relative w-64 h-64 rounded-full overflow-hidden border-4 border-accent/30 bg-black group">
|
<Cropper
|
||||||
{cropFile?.type.startsWith('video/') ? (
|
image={cropImage}
|
||||||
<video src={cropImage} className="w-full h-full object-cover opacity-80" autoPlay muted loop />
|
crop={crop}
|
||||||
) : (
|
zoom={zoom}
|
||||||
<img src={cropImage} className="w-full h-full object-cover opacity-80" alt="" />
|
aspect={1}
|
||||||
)}
|
cropShape="round"
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
showGrid={false}
|
||||||
<div className="w-48 h-48 rounded-full border-2 border-dashed border-white/50 animate-pulse pointer-events-none" />
|
onCropChange={setCrop}
|
||||||
</div>
|
onZoomChange={setZoom}
|
||||||
{/* Fake cropping area overlay */}
|
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
|
||||||
<div className="absolute inset-0 bg-black/40 pointer-events-none" style={{
|
/>
|
||||||
clipPath: 'circle(48% at 50% 50%)'
|
</div>
|
||||||
}} />
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={zoom}
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
aria-labelledby="Zoom"
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-vortex-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-sm text-zinc-400 text-center px-4 leading-relaxed">
|
<div className="flex gap-3 w-full">
|
||||||
{t('photoVideo')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-8 w-full">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCropping(false)}
|
onClick={() => setIsCropping(false)}
|
||||||
className="flex-1 py-3.5 rounded-2xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCropSave}
|
onClick={handleCropSave}
|
||||||
disabled={tabLoading}
|
disabled={tabLoading}
|
||||||
className="flex-1 py-3.5 rounded-2xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{tabLoading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
{tabLoading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||||
{t('save')}
|
{t('save')}
|
||||||
@@ -705,6 +854,16 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<ImageLightbox
|
||||||
|
images={allMedia.map(m => ({ url: m.url, type: m.type }))}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// \u0413\u0440\u0443\u043f\u043f\u044b
|
// \u0413\u0440\u0443\u043f\u043f\u044b
|
||||||
async updateGroup(chatId: string, data: { name?: string }) {
|
async updateGroup(chatId: string, data: { name?: string; description?: string }) {
|
||||||
return this.request<Chat>(`/chats/${chatId}`, {
|
return this.request<Chat>(`/chats/${chatId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -203,6 +203,26 @@ class ApiClient {
|
|||||||
return response.json() as Promise<Chat>;
|
return response.json() as Promise<Chat>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
formData.append('x', cropData.x.toString());
|
||||||
|
formData.append('y', cropData.y.toString());
|
||||||
|
formData.append('width', cropData.width.toString());
|
||||||
|
formData.append('height', cropData.height.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar/crop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Ошибка кропа аватара');
|
||||||
|
return response.json() as Promise<Chat>;
|
||||||
|
}
|
||||||
|
|
||||||
async removeGroupAvatar(chatId: string) {
|
async removeGroupAvatar(chatId: string) {
|
||||||
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ const translations = {
|
|||||||
removeMember: 'Удалить из группы',
|
removeMember: 'Удалить из группы',
|
||||||
leaveGroup: 'Покинуть группу',
|
leaveGroup: 'Покинуть группу',
|
||||||
adminBadge: 'Админ',
|
adminBadge: 'Админ',
|
||||||
|
groupDescription: 'Описание',
|
||||||
|
noDescription: 'Нет описания',
|
||||||
memberBadge: 'Участник',
|
memberBadge: 'Участник',
|
||||||
screenShare: 'Демонстрация экрана',
|
screenShare: 'Демонстрация экрана',
|
||||||
stopScreenShare: 'Остановить демонстрацию',
|
stopScreenShare: 'Остановить демонстрацию',
|
||||||
@@ -179,7 +181,8 @@ const translations = {
|
|||||||
sharedFiles: 'Общие файлы будут здесь',
|
sharedFiles: 'Общие файлы будут здесь',
|
||||||
sharedLinks: 'Общие ссылки будут здесь',
|
sharedLinks: 'Общие ссылки будут здесь',
|
||||||
profileNotFound: 'Профиль не найден',
|
profileNotFound: 'Профиль не найден',
|
||||||
storiesTab: 'Публикации',
|
storiesTab: 'Истории',
|
||||||
|
publicationsTab: 'Публикации',
|
||||||
noStories: 'Публикаций пока нет',
|
noStories: 'Публикаций пока нет',
|
||||||
goToMessage: 'Перейти к сообщению',
|
goToMessage: 'Перейти к сообщению',
|
||||||
story: 'История',
|
story: 'История',
|
||||||
@@ -405,6 +408,8 @@ const translations = {
|
|||||||
removeMember: 'Remove from group',
|
removeMember: 'Remove from group',
|
||||||
leaveGroup: 'Leave group',
|
leaveGroup: 'Leave group',
|
||||||
adminBadge: 'Admin',
|
adminBadge: 'Admin',
|
||||||
|
groupDescription: 'Description',
|
||||||
|
noDescription: 'No description',
|
||||||
memberBadge: 'Member',
|
memberBadge: 'Member',
|
||||||
screenShare: 'Screen share',
|
screenShare: 'Screen share',
|
||||||
stopScreenShare: 'Stop sharing',
|
stopScreenShare: 'Stop sharing',
|
||||||
@@ -442,6 +447,7 @@ const translations = {
|
|||||||
sharedLinks: 'Shared links will appear here',
|
sharedLinks: 'Shared links will appear here',
|
||||||
profileNotFound: 'Profile not found',
|
profileNotFound: 'Profile not found',
|
||||||
storiesTab: 'Stories',
|
storiesTab: 'Stories',
|
||||||
|
publicationsTab: 'Publications',
|
||||||
noStories: 'No stories yet',
|
noStories: 'No stories yet',
|
||||||
goToMessage: 'Go to message',
|
goToMessage: 'Go to message',
|
||||||
story: 'Story',
|
story: 'Story',
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const getCroppedImg = async (
|
||||||
|
imageSrc: string,
|
||||||
|
pixelCrop: { x: number; y: number; width: number; height: number }
|
||||||
|
): Promise<File | null> => {
|
||||||
|
const image = await createImage(imageSrc);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sizing
|
||||||
|
canvas.width = pixelCrop.width;
|
||||||
|
canvas.height = pixelCrop.height;
|
||||||
|
|
||||||
|
// Draw cropped image
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||||
|
resolve(file);
|
||||||
|
}, 'image/jpeg', 0.95);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.addEventListener('load', () => resolve(image));
|
||||||
|
image.addEventListener('error', (error) => reject(error));
|
||||||
|
image.setAttribute('crossOrigin', 'anonymous');
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ export interface Chat {
|
|||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
description?: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
members: ChatMember[];
|
members: ChatMember[];
|
||||||
|
|||||||
@@ -137,3 +137,13 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
|
|||||||
return Array(bars).fill(0.5);
|
return Array(bars).fill(0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMediaUrl(url: string | null | undefined): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||||
|
|
||||||
|
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
|
||||||
|
// will resolve against the current origin (port).
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/imagecrop.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts","./src/stores/usestorystore.ts"],"version":"5.9.3"}
|
||||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
envDir: '../../',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
|
args:
|
||||||
|
- VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY}
|
||||||
container_name: vortex-web
|
container_name: vortex-web
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9090:80"
|
- "9090:80"
|
||||||
|
environment:
|
||||||
|
- VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
|
|
||||||
|
|||||||