Add mediator
This commit is contained in:
parent
3b446fa885
commit
9c5eaf2644
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Todo.Core.Interfaces.Persistence;
|
||||
@ -17,8 +18,14 @@ public class TodosController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request) =>
|
||||
Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty));
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request)
|
||||
{
|
||||
var userId = User.FindFirstValue("sub") ??
|
||||
throw new InvalidOperationException("Could not get user, something has gone terribly wrong");
|
||||
|
||||
return Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty, userId));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
|
@ -1,11 +1,15 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Todo.Api.Hubs.Models;
|
||||
using Todo.Core.Application.Commands.Todo;
|
||||
using Todo.Core.Application.Services.UserConnectionStore;
|
||||
using Todo.Core.Interfaces.Persistence;
|
||||
using Todo.Core.Interfaces.User;
|
||||
using Todo.Infrastructure;
|
||||
|
||||
namespace Todo.Api.Hubs;
|
||||
|
||||
@ -13,85 +17,59 @@ namespace Todo.Api.Hubs;
|
||||
public class TodoHub : Hub
|
||||
{
|
||||
private readonly ITodoRepository _todoRepository;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new();
|
||||
private readonly IUserConnectionStore _userConnectionStore;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public override Task OnConnectedAsync()
|
||||
{
|
||||
var userId = Context.User.FindFirstValue("sub");
|
||||
|
||||
// Try to get a List of existing user connections from the cache
|
||||
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
|
||||
|
||||
// happens on the very first connection from the user
|
||||
existingUserConnectionIds ??= new List<string>();
|
||||
|
||||
// First add to a List of existing user connections (i.e. multiple web browser tabs)
|
||||
existingUserConnectionIds.Add(Context.ConnectionId);
|
||||
|
||||
|
||||
// Add to the global dictionary of connected users
|
||||
ConnectedUsers.TryAdd(userId, existingUserConnectionIds);
|
||||
|
||||
var userId = _currentUserService.GetUserId();
|
||||
if (userId is not null)
|
||||
_userConnectionStore.AddAsync(userId, Context.ConnectionId);
|
||||
return base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var userId = Context.User.FindFirstValue("sub");
|
||||
|
||||
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
|
||||
|
||||
// remove the connection id from the List
|
||||
existingUserConnectionIds?.Remove(Context.ConnectionId);
|
||||
|
||||
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
|
||||
if (existingUserConnectionIds?.Count == 0)
|
||||
{
|
||||
// if there are no connections for the user,
|
||||
// just delete the userName key from the ConnectedUsers concurent dictionary
|
||||
ConnectedUsers.TryRemove(userId, out _);
|
||||
}
|
||||
var userId = _currentUserService.GetUserId();
|
||||
if (userId is not null)
|
||||
_userConnectionStore.RemoveAsync(userId, Context.ConnectionId);
|
||||
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
|
||||
public TodoHub(ITodoRepository todoRepository)
|
||||
public TodoHub(
|
||||
ITodoRepository todoRepository,
|
||||
IUserConnectionStore userConnectionStore,
|
||||
ICurrentUserService currentUserService,
|
||||
IMediator mediator)
|
||||
{
|
||||
_todoRepository = todoRepository;
|
||||
_userConnectionStore = userConnectionStore;
|
||||
_currentUserService = currentUserService;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task CreateTodo(string todoTitle, string projectName)
|
||||
public async Task CreateTodo(string todoTitle, string? projectName)
|
||||
{
|
||||
if (todoTitle is null)
|
||||
throw new ArgumentException("title cannot be null");
|
||||
var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName);
|
||||
|
||||
var todos = await _todoRepository.GetNotDoneTodos();
|
||||
var serializedTodos =
|
||||
JsonSerializer.Serialize(todos
|
||||
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Project = t.Project })
|
||||
.ToList());
|
||||
//var userId = GetUserId();
|
||||
|
||||
await RunOnUserConnections(async (connections) =>
|
||||
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos));
|
||||
//var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName, userId);
|
||||
await _mediator.Send(new CreateTodoCommand(todoTitle, projectName));
|
||||
|
||||
await GetInboxTodos();
|
||||
}
|
||||
|
||||
|
||||
public async Task UpdateTodo(string todoId, bool todoStatus)
|
||||
{
|
||||
await _todoRepository.UpdateTodoStatus(todoId, todoStatus);
|
||||
var userId = GetUserId();
|
||||
await _todoRepository.UpdateTodoStatus(todoId, todoStatus, userId);
|
||||
|
||||
var todos = await _todoRepository.GetNotDoneTodos();
|
||||
var serializedTodos =
|
||||
JsonSerializer.Serialize(todos
|
||||
.Select(t => new TodoResponse
|
||||
{ Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project })
|
||||
.ToList());
|
||||
|
||||
await RunOnUserConnections(async (connections) =>
|
||||
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos));
|
||||
await GetInboxTodos();
|
||||
}
|
||||
|
||||
public async Task GetTodos()
|
||||
@ -157,17 +135,15 @@ public class TodoHub : Hub
|
||||
await Clients.Clients(connections).SendAsync("getTodo", serializedTodo));
|
||||
}
|
||||
|
||||
private Task RunOnUserConnections(Func<IEnumerable<string>, Task> action)
|
||||
private async Task RunOnUserConnections(Func<IEnumerable<string>, Task> action)
|
||||
{
|
||||
var userId = Context.User.FindFirstValue("sub");
|
||||
if (userId is null)
|
||||
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
|
||||
var userId = GetUserId();
|
||||
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
|
||||
|
||||
ConnectedUsers.TryGetValue(userId, out var connections);
|
||||
|
||||
if (connections is not null)
|
||||
action(connections);
|
||||
|
||||
return Task.CompletedTask;
|
||||
await action(connections);
|
||||
}
|
||||
|
||||
private string GetUserId() =>
|
||||
_currentUserService.GetUserId() ??
|
||||
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
|
||||
}
|
42
src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs
Normal file
42
src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Todo.Api.Hubs;
|
||||
using Todo.Core.Application.Services.UserConnectionStore;
|
||||
using Todo.Core.Interfaces.Publisher;
|
||||
using Todo.Core.Interfaces.User;
|
||||
|
||||
namespace Todo.Api.Publishers;
|
||||
|
||||
public class TodoPublisher : ITodoPublisher
|
||||
{
|
||||
private readonly IHubContext<TodoHub> _hubContext;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IUserConnectionStore _userConnectionStore;
|
||||
private readonly ILogger<TodoPublisher> _logger;
|
||||
|
||||
public TodoPublisher(
|
||||
IHubContext<TodoHub> hubContext,
|
||||
ICurrentUserService currentUserService,
|
||||
IUserConnectionStore userConnectionStore,
|
||||
ILogger<TodoPublisher> logger)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_currentUserService = currentUserService;
|
||||
_userConnectionStore = userConnectionStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Publish(string todoId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = _currentUserService.GetUserId() ?? throw new InvalidOperationException("Cannot proceed without user");
|
||||
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
|
||||
|
||||
await _hubContext
|
||||
.Clients
|
||||
.Clients(connections)
|
||||
.SendAsync("todoCreated", todoId , cancellationToken);
|
||||
|
||||
_logger.LogInformation("todo created {TodoId}", todoId);
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Todo.Core.Interfaces.User;
|
||||
|
||||
namespace Todo.Api.Services;
|
||||
|
||||
public class HttpContextCurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public HttpContextCurrentUserService(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public string? GetUserId() => _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
|
||||
}
|
@ -8,9 +8,14 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Todo.Api.Hubs;
|
||||
using Todo.Api.Publishers;
|
||||
using Todo.Api.Services;
|
||||
using Todo.Core.Interfaces.User;
|
||||
using Todo.Infrastructure;
|
||||
using Todo.Persistence;
|
||||
using Todo.Persistence.Mongo;
|
||||
using Todo.Core;
|
||||
using Todo.Core.Interfaces.Publisher;
|
||||
|
||||
namespace Todo.Api
|
||||
{
|
||||
@ -37,6 +42,10 @@ namespace Todo.Api
|
||||
.AllowAnyMethod());
|
||||
});
|
||||
|
||||
services.AddCore();
|
||||
services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
|
||||
services.AddScoped<ITodoPublisher, TodoPublisher>();
|
||||
|
||||
services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo.Api", Version = "v1" });
|
||||
|
@ -0,0 +1,37 @@
|
||||
using System.Threading;
|
||||
using MediatR;
|
||||
using Todo.Core.Application.Notifications.Todo;
|
||||
using Todo.Core.Interfaces.Persistence;
|
||||
using Todo.Core.Interfaces.User;
|
||||
|
||||
namespace Todo.Core.Application.Commands.Todo;
|
||||
|
||||
public record CreateTodoCommand(string TodoTitle, string? TodoProject) : IRequest<string>
|
||||
{
|
||||
internal class Handler : IRequestHandler<CreateTodoCommand, string>
|
||||
{
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly ITodoRepository _todoRepository;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public Handler(ICurrentUserService currentUserService, ITodoRepository todoRepository, IMediator mediator)
|
||||
{
|
||||
_currentUserService = currentUserService;
|
||||
_todoRepository = todoRepository;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<string> Handle(CreateTodoCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = _currentUserService.GetUserId();
|
||||
if (userId is null)
|
||||
throw new InvalidOperationException("User was not found");
|
||||
|
||||
var todo = await _todoRepository.CreateTodoAsync(request.TodoTitle, request.TodoProject, userId);
|
||||
|
||||
await _mediator.Publish(new TodoCreated(todo.Id), cancellationToken);
|
||||
|
||||
return todo.Id;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Todo.Core.Application.Services.UserConnectionStore;
|
||||
using Todo.Core.Interfaces.Publisher;
|
||||
using Todo.Core.Interfaces.User;
|
||||
|
||||
namespace Todo.Core.Application.Notifications.Todo;
|
||||
|
||||
public record TodoCreated([property: JsonPropertyName("todoId")] string TodoId) : INotification
|
||||
{
|
||||
internal class Handler : INotificationHandler<TodoCreated>
|
||||
{
|
||||
private readonly ITodoPublisher _todoPublisher;
|
||||
|
||||
public Handler(ITodoPublisher todoPublisher)
|
||||
{
|
||||
_todoPublisher = todoPublisher;
|
||||
}
|
||||
|
||||
public async Task Handle(TodoCreated notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await _todoPublisher.Publish(JsonSerializer.Serialize(notification), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace Todo.Core.Interfaces.Publisher;
|
||||
|
||||
public interface ITodoPublisher
|
||||
{
|
||||
Task Publish(string todoId, CancellationToken cancellationToken = new ());
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
namespace Todo.Core.Application.Services.UserConnectionStore;
|
||||
|
||||
public interface IUserConnectionStore
|
||||
{
|
||||
Task AddAsync(string userId, string connectionId);
|
||||
Task RemoveAsync(string userId, string connectionId);
|
||||
Task<IEnumerable<string>> GetConnectionsAsync(string userId);
|
||||
}
|
@ -2,9 +2,13 @@ global using System;
|
||||
global using System.Linq;
|
||||
global using System.Threading.Tasks;
|
||||
global using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Todo.Core;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddCore(this IServiceCollection services) => services.AddMediatR(Assembly.GetExecutingAssembly());
|
||||
}
|
@ -3,9 +3,9 @@ namespace Todo.Core.Interfaces.Persistence;
|
||||
|
||||
public interface ITodoRepository
|
||||
{
|
||||
Task<Entities.Todo> CreateTodoAsync(string title, string projectName);
|
||||
Task<Entities.Todo> CreateTodoAsync(string title, string projectName, string userId);
|
||||
Task<IEnumerable<Entities.Todo>> GetTodosAsync();
|
||||
Task UpdateTodoStatus(string todoId, bool todoStatus);
|
||||
Task UpdateTodoStatus(string todoId, bool todoStatus, string userId);
|
||||
Task<IEnumerable<Entities.Todo>> GetNotDoneTodos();
|
||||
Task<Entities.Todo> GetTodoByIdAsync(string todoId);
|
||||
Task<Entities.Todo> UpdateTodoAsync(Entities.Todo todo);
|
||||
|
@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Persistence;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User> Register(string email, string password);
|
||||
Task<Entities.User> Register(string email, string password);
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Todo.Core.Interfaces.User;
|
||||
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
string? GetUserId();
|
||||
}
|
@ -2,6 +2,20 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Application\Services" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mediatr" Version="9.0.0" />
|
||||
<PackageReference Include="Mediatr.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -3,7 +3,8 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Todo.Core.Application.Services.UserConnectionStore;
|
||||
using Todo.Infrastructure.UserConnectionStore;
|
||||
|
||||
namespace Todo.Infrastructure;
|
||||
|
||||
@ -20,6 +21,7 @@ public static class DependencyInjection
|
||||
.Bind(giteaOptions)
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddSingleton<IUserConnectionStore, InMemoryUserConnectionStore>();
|
||||
|
||||
return services.AddAuthentication(options =>
|
||||
{
|
||||
|
@ -14,4 +14,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -0,0 +1,50 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Todo.Core.Application.Services.UserConnectionStore;
|
||||
|
||||
namespace Todo.Infrastructure.UserConnectionStore;
|
||||
|
||||
class InMemoryUserConnectionStore : IUserConnectionStore
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new();
|
||||
|
||||
public Task AddAsync(string userId, string connectionId)
|
||||
{
|
||||
// Try to get a List of existing user connections from the cache
|
||||
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
|
||||
|
||||
// happens on the very first connection from the user
|
||||
existingUserConnectionIds ??= new List<string>();
|
||||
|
||||
// First add to a List of existing user connections (i.e. multiple web browser tabs)
|
||||
existingUserConnectionIds.Add(connectionId);
|
||||
|
||||
// Add to the global dictionary of connected users
|
||||
ConnectedUsers.TryAdd(userId, existingUserConnectionIds);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> GetConnectionsAsync(string userId)
|
||||
{
|
||||
ConnectedUsers.TryGetValue(userId, out var connections);
|
||||
return Task.FromResult(connections is null ? new List<string>().AsEnumerable() : connections.AsEnumerable());
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string userId, string connectionId)
|
||||
{
|
||||
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
|
||||
|
||||
// remove the connection id from the List
|
||||
existingUserConnectionIds?.Remove(connectionId);
|
||||
|
||||
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
|
||||
if (existingUserConnectionIds?.Count == 0)
|
||||
{
|
||||
// if there are no connections for the user,
|
||||
// just delete the userName key from the ConnectedUsers concurent dictionary
|
||||
ConnectedUsers.TryRemove(userId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
@ -12,4 +12,5 @@ public record MongoTodo
|
||||
[BsonRequired] public string Title { get; init; }
|
||||
[BsonRequired] public bool Status { get; set; }
|
||||
public string ProjectName { get; set; } = String.Empty;
|
||||
public string AuthorId { get; set; }
|
||||
}
|
@ -15,9 +15,9 @@ public class TodoRepository : ITodoRepository
|
||||
_todosCollection = database.GetCollection<MongoTodo>("todos");
|
||||
}
|
||||
|
||||
public async Task<Core.Entities.Todo> CreateTodoAsync(string title, string projectName)
|
||||
public async Task<Core.Entities.Todo> CreateTodoAsync(string title, string projectName, string userId)
|
||||
{
|
||||
var todo = new MongoTodo() { Title = title, ProjectName = projectName };
|
||||
var todo = new MongoTodo() { Title = title, ProjectName = projectName, AuthorId = userId };
|
||||
await _todosCollection.InsertOneAsync(todo);
|
||||
return new Core.Entities.Todo()
|
||||
{ Id = todo.Id, Title = todo.Title, Status = false, Project = todo.ProjectName };
|
||||
@ -32,10 +32,10 @@ public class TodoRepository : ITodoRepository
|
||||
new Core.Entities.Todo() { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.ProjectName });
|
||||
}
|
||||
|
||||
public async Task UpdateTodoStatus(string todoId, bool todoStatus)
|
||||
public async Task UpdateTodoStatus(string todoId, bool todoStatus, string userId)
|
||||
{
|
||||
await _todosCollection
|
||||
.UpdateOneAsync(t => t.Id == todoId,
|
||||
.UpdateOneAsync(t => t.Id == todoId && t.AuthorId == userId,
|
||||
Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus));
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ export const TodoCheckmark: FC<TodoCheckmarkProps> = (props) => (
|
||||
onClick={() =>
|
||||
props.updateTodo({ ...props.todo, status: !props.todo.status })
|
||||
}
|
||||
className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 ${
|
||||
className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 cursor-pointer ${
|
||||
props.todo.status === StatusState.done
|
||||
? "bg-gray-300 dark:bg-gray-500"
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-600"
|
||||
|
Loading…
Reference in New Issue
Block a user