diff --git a/LANCommander.Launcher/Services/WindowService.cs b/LANCommander.Launcher/Services/WindowService.cs index 46c1a26d..6eceb780 100644 --- a/LANCommander.Launcher/Services/WindowService.cs +++ b/LANCommander.Launcher/Services/WindowService.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Options; using Photino.Blazor; using Photino.Blazor.CustomWindow.Extensions; using System.Web; +using LANCommander.UI.Extensions; namespace LANCommander.Launcher.Services; @@ -42,6 +43,7 @@ internal static class WindowService builder.Services.AddCustomWindow(); builder.Services.AddAntDesign(); builder.Services.AddSingleton(); + builder.Services.AddLANCommanderUI(); builder.Services.AddLANCommanderClient(); builder.Services.AddLANCommanderLauncher(options => { diff --git a/LANCommander.Launcher/UI/Windows/Window.scss b/LANCommander.Launcher/UI/Windows/Window.scss index 97e86698..6496a348 100644 --- a/LANCommander.Launcher/UI/Windows/Window.scss +++ b/LANCommander.Launcher/UI/Windows/Window.scss @@ -22,10 +22,6 @@ background-color: rgba(255, 255, 255, 1); } -* { - user-select: none; -} - .hidden { display: none; } diff --git a/LANCommander.SDK/Clients/ChatClient.cs b/LANCommander.SDK/Clients/ChatClient.cs index 19105c5b..3cd37db6 100644 --- a/LANCommander.SDK/Clients/ChatClient.cs +++ b/LANCommander.SDK/Clients/ChatClient.cs @@ -195,7 +195,7 @@ public class ChatClient : IChatClient, HubChatClient await _hub.UpdateReadStatusAsync(thread.Id); } - public async Task> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count) + public async Task> GetMessagesAsync(Guid threadId, Guid? cursor, int count) { await EnsureConnectedAsync(); diff --git a/LANCommander.SDK/Clients/ChatHubClient.cs b/LANCommander.SDK/Clients/ChatHubClient.cs index ef84ae87..977496d7 100644 --- a/LANCommander.SDK/Clients/ChatHubClient.cs +++ b/LANCommander.SDK/Clients/ChatHubClient.cs @@ -199,7 +199,7 @@ public class ChatHubClient : IChatClient, IChatHubClient await _hub.UpdateReadStatusAsync(thread.Id); } - public async Task> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count) + public async Task> GetMessagesAsync(Guid threadId, Guid? cursor, int count) { await EnsureConnectedAsync(); diff --git a/LANCommander.SDK/Clients/IChatClient.cs b/LANCommander.SDK/Clients/IChatClient.cs index fccf3b1a..f80bb7af 100644 --- a/LANCommander.SDK/Clients/IChatClient.cs +++ b/LANCommander.SDK/Clients/IChatClient.cs @@ -17,6 +17,6 @@ public interface IChatClient Task StopTypingAsync(Guid threadId, string userId); Task SendMessageAsync(Guid threadId, string message); Task UpdatedReadStatus(Guid threadId); - Task> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count); + Task> GetMessagesAsync(Guid threadId, Guid? cursor, int count); Task GetUnreadMessageCountAsync(Guid threadId); } \ No newline at end of file diff --git a/LANCommander.SDK/Hubs/Interfaces/IChatClient.cs b/LANCommander.SDK/Hubs/Interfaces/IChatClient.cs index 13c21c7d..79f1309c 100644 --- a/LANCommander.SDK/Hubs/Interfaces/IChatClient.cs +++ b/LANCommander.SDK/Hubs/Interfaces/IChatClient.cs @@ -14,3 +14,5 @@ public interface IChatClient Task StopTypingAsync(Guid threadId, string userIdentifier); } + + diff --git a/LANCommander.SDK/Hubs/Interfaces/IChatHub.cs b/LANCommander.SDK/Hubs/Interfaces/IChatHub.cs index aea9f47c..d375a31f 100644 --- a/LANCommander.SDK/Hubs/Interfaces/IChatHub.cs +++ b/LANCommander.SDK/Hubs/Interfaces/IChatHub.cs @@ -12,7 +12,7 @@ public interface IChatHub Task GetThreadAsync(Guid threadId); Task> GetThreadsAsync(); Task SendMessageAsync(Guid threadId, string message); - Task> GetMessagesAsync(Guid threadId, ChatMessage? cursor, int? count); + Task> GetMessagesAsync(Guid threadId, Guid? cursor, int? count); Task StartTypingAsync(Guid threadId); Task StopTypingAsync(Guid threadId); Task UpdateReadStatusAsync(Guid threadId); diff --git a/LANCommander.SDK/Models/InfiniteResponse.cs b/LANCommander.SDK/Models/InfiniteResponse.cs new file mode 100644 index 00000000..860a8506 --- /dev/null +++ b/LANCommander.SDK/Models/InfiniteResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace LANCommander.SDK.Models; + +public class InfiniteResponse +{ + public IEnumerable Items { get; set; } + public bool HasMore { get; set; } +} \ No newline at end of file diff --git a/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.Designer.cs b/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.Designer.cs new file mode 100644 index 00000000..68c9e236 --- /dev/null +++ b/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.Designer.cs @@ -0,0 +1,2992 @@ +// +using System; +using LANCommander.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LANCommander.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260111073107_ChatReadStatusMessageId")] + partial class ChatReadStatusMessageId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("CategoryGame", b => + { + b.Property("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "GamesId"); + + b.HasIndex("GamesId"); + + b.ToTable("CategoryGame"); + }); + + modelBuilder.Entity("ChatThreadUser", b => + { + b.Property("ChatThreadsId") + .HasColumnType("TEXT"); + + b.Property("ParticipantsId") + .HasColumnType("TEXT"); + + b.HasKey("ChatThreadsId", "ParticipantsId"); + + b.HasIndex("ParticipantsId"); + + b.ToTable("ChatThreadUser"); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("CollectionGame"); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("DeveloperId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "DeveloperId"); + + b.HasIndex("DeveloperId"); + + b.ToTable("GameDeveloper"); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("GenresId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("GameGenre"); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("PlatformsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "PlatformsId"); + + b.HasIndex("PlatformsId"); + + b.ToTable("GamePlatform"); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("PublisherId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "PublisherId"); + + b.HasIndex("PublisherId"); + + b.ToTable("GamePublisher"); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "RedistributableId"); + + b.HasIndex("RedistributableId"); + + b.ToTable("GameRedistributable"); + }); + + modelBuilder.Entity("GameTag", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("GameTag"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Action", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PrimaryAction") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("WorkingDirectory") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("ServerId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Actions"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Archive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Changelog") + .HasColumnType("TEXT"); + + b.Property("CompressedSize") + .HasColumnType("INTEGER"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("LastVersionId") + .HasColumnType("TEXT"); + + b.Property("ObjectKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.Property("StorageLocationId") + .HasColumnType("TEXT"); + + b.Property("UncompressedSize") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("LastVersionId"); + + b.HasIndex("RedistributableId"); + + b.HasIndex("StorageLocationId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Archive"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ParentId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b => + { + b.Property("ThreadId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("TEXT"); + + b.HasKey("ThreadId", "UserId"); + + b.HasIndex("LastReadMessageId"); + + b.HasIndex("UserId"); + + b.ToTable("ChatThreadReadStatuses"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Engine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Engines"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaseGameId") + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DirectoryName") + .HasColumnType("TEXT"); + + b.Property("EngineId") + .HasColumnType("TEXT"); + + b.Property("IGDBId") + .HasColumnType("INTEGER"); + + b.Property("KeyAllocationMethod") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("ReleasedOn") + .HasColumnType("TEXT"); + + b.Property("Singleplayer") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("ValidKeyRegex") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaseGameId"); + + b.HasIndex("CreatedById"); + + b.HasIndex("EngineId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.GameCustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("GameCustomField"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.GameSave", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StorageLocationId") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("StorageLocationId"); + + b.HasIndex("UpdatedById"); + + b.HasIndex("UserId"); + + b.ToTable("GameSaves"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Issue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ResolvedById") + .HasColumnType("TEXT"); + + b.Property("ResolvedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("ResolvedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Issues"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllocationMethod") + .HasColumnType("INTEGER"); + + b.Property("ClaimedByComputerName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ClaimedByIpv4Address") + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("ClaimedByMacAddress") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ClaimedByUserId") + .HasColumnType("TEXT"); + + b.Property("ClaimedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClaimedByUserId"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Crc32") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("FileId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("StorageLocationId") + .HasColumnType("TEXT"); + + b.Property("ThumbnailId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("StorageLocationId"); + + b.HasIndex("ThumbnailId") + .IsUnique(); + + b.HasIndex("UpdatedById"); + + b.HasIndex("UserId"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.MultiplayerMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("NetworkProtocol") + .HasColumnType("INTEGER"); + + b.Property("Spectators") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("MultiplayerModes"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Contents") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ParentId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Pages"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Platform", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Platforms"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.PlaySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.HasIndex("UserId"); + + b.ToTable("PlaySessions"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Redistributable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Redistributables"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.SavePath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("IsRegex") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("WorkingDirectory") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("SavePaths"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Script", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Contents") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.Property("RequiresAdmin") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("RedistributableId"); + + b.HasIndex("ServerId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Scripts"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Autostart") + .HasColumnType("INTEGER"); + + b.Property("AutostartDelay") + .HasColumnType("INTEGER"); + + b.Property("AutostartMethod") + .HasColumnType("INTEGER"); + + b.Property("ContainerId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DockerHostId") + .HasColumnType("TEXT"); + + b.Property("Engine") + .HasColumnType("INTEGER"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OnStartScriptPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OnStopScriptPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessTerminationMethod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UseShellExecute") + .HasColumnType("INTEGER"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("GameId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Servers"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ServerConsole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ServerId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("ServerConsoles"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ServerHttpPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("ServerId"); + + b.HasIndex("UpdatedById"); + + b.ToTable("ServerHttpPath"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.StorageLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Default") + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("StorageLocations"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("ApprovedOn") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RefreshToken") + .HasColumnType("TEXT"); + + b.Property("RefreshTokenExpiration") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UpdatedById"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserCustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedById") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("UpdatedById"); + + b.HasIndex("UserId"); + + b.ToTable("UserCustomField"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "LibraryId"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryGame"); + }); + + modelBuilder.Entity("PageGame", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("PageId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "PageId"); + + b.HasIndex("PageId"); + + b.ToTable("PageGame"); + }); + + modelBuilder.Entity("PageRedistributable", b => + { + b.Property("PageId") + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.HasKey("PageId", "RedistributableId"); + + b.HasIndex("RedistributableId"); + + b.ToTable("PageRedistributable"); + }); + + modelBuilder.Entity("PageServer", b => + { + b.Property("PageId") + .HasColumnType("TEXT"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("PageId", "ServerId"); + + b.HasIndex("ServerId"); + + b.ToTable("PageServer"); + }); + + modelBuilder.Entity("RoleCollection", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleCollection"); + }); + + modelBuilder.Entity("CategoryGame", b => + { + b.HasOne("LANCommander.Server.Data.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatThreadUser", b => + { + b.HasOne("LANCommander.Server.Data.Models.ChatThread", null) + .WithMany() + .HasForeignKey("ChatThreadsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", null) + .WithMany() + .HasForeignKey("ParticipantsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.HasOne("LANCommander.Server.Data.Models.Collection", null) + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.HasOne("LANCommander.Server.Data.Models.Company", null) + .WithMany() + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Platform", null) + .WithMany() + .HasForeignKey("PlatformsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Company", null) + .WithMany() + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Redistributable", null) + .WithMany() + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameTag", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Action", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Actions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.Server", "Server") + .WithMany("Actions") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("Server"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Archive", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Archives") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.Archive", "LastVersion") + .WithMany() + .HasForeignKey("LastVersionId"); + + b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable") + .WithMany("Archives") + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.StorageLocation", "StorageLocation") + .WithMany("Archives") + .HasForeignKey("StorageLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("LastVersion"); + + b.Navigation("Redistributable"); + + b.Navigation("StorageLocation"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Category", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId"); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Parent"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatMessage", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.ChatThread", "Thread") + .WithMany("Messages") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Thread"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThread", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b => + { + b.HasOne("LANCommander.Server.Data.Models.ChatMessage", "LastReadMessage") + .WithMany() + .HasForeignKey("LastReadMessageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.ChatThread", "Thread") + .WithMany() + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LastReadMessage"); + + b.Navigation("Thread"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Collection", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Company", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Engine", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById"); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Game", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", "BaseGame") + .WithMany("DependentGames") + .HasForeignKey("BaseGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Engine", "Engine") + .WithMany("Games") + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseGame"); + + b.Navigation("CreatedBy"); + + b.Navigation("Engine"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.GameCustomField", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("CustomFields") + .HasForeignKey("GameId"); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById"); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.GameSave", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("GameSaves") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.StorageLocation", "StorageLocation") + .WithMany("GameSaves") + .HasForeignKey("StorageLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany("GameSaves") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("StorageLocation"); + + b.Navigation("UpdatedBy"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Genre", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Issue", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Issues") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "ResolvedBy") + .WithMany() + .HasForeignKey("ResolvedById"); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("ResolvedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Key", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "ClaimedByUser") + .WithMany() + .HasForeignKey("ClaimedByUserId"); + + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Keys") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ClaimedByUser"); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Library", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithOne("Library") + .HasForeignKey("LANCommander.Server.Data.Models.Library", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Media") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.StorageLocation", "StorageLocation") + .WithMany("Media") + .HasForeignKey("StorageLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Media", "Thumbnail") + .WithOne("Parent") + .HasForeignKey("LANCommander.Server.Data.Models.Media", "ThumbnailId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany("Media") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("StorageLocation"); + + b.Navigation("Thumbnail"); + + b.Navigation("UpdatedBy"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.MultiplayerMode", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("MultiplayerModes") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Page", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Page", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Parent"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Platform", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.PlaySession", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("PlaySessions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany("PlaySessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Redistributable", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Role", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.RoleClaim", b => + { + b.HasOne("LANCommander.Server.Data.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.SavePath", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("SavePaths") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Script", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Scripts") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.Redistributable", "Redistributable") + .WithMany("Scripts") + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.Server", "Server") + .WithMany("Scripts") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("Redistributable"); + + b.Navigation("Server"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Server", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Game", "Game") + .WithMany("Servers") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Game"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ServerConsole", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Server", "Server") + .WithMany("ServerConsoles") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Server"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ServerHttpPath", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.Server", "Server") + .WithMany("HttpPaths") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("Server"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.StorageLocation", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById"); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Tag", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.User", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserClaim", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserCustomField", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "UpdatedBy") + .WithMany() + .HasForeignKey("UpdatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany("CustomFields") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("UpdatedBy"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserLogin", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserRole", b => + { + b.HasOne("LANCommander.Server.Data.Models.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.UserToken", b => + { + b.HasOne("LANCommander.Server.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PageGame", b => + { + b.HasOne("LANCommander.Server.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Page", null) + .WithMany() + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PageRedistributable", b => + { + b.HasOne("LANCommander.Server.Data.Models.Page", null) + .WithMany() + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Redistributable", null) + .WithMany() + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PageServer", b => + { + b.HasOne("LANCommander.Server.Data.Models.Page", null) + .WithMany() + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Server", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RoleCollection", b => + { + b.HasOne("LANCommander.Server.Data.Models.Collection", null) + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Server.Data.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThread", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Engine", b => + { + b.Navigation("Games"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Game", b => + { + b.Navigation("Actions"); + + b.Navigation("Archives"); + + b.Navigation("CustomFields"); + + b.Navigation("DependentGames"); + + b.Navigation("GameSaves"); + + b.Navigation("Issues"); + + b.Navigation("Keys"); + + b.Navigation("Media"); + + b.Navigation("MultiplayerModes"); + + b.Navigation("PlaySessions"); + + b.Navigation("SavePaths"); + + b.Navigation("Scripts"); + + b.Navigation("Servers"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Media", b => + { + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Page", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Redistributable", b => + { + b.Navigation("Archives"); + + b.Navigation("Scripts"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Role", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.Server", b => + { + b.Navigation("Actions"); + + b.Navigation("HttpPaths"); + + b.Navigation("Scripts"); + + b.Navigation("ServerConsoles"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.StorageLocation", b => + { + b.Navigation("Archives"); + + b.Navigation("GameSaves"); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("LANCommander.Server.Data.Models.User", b => + { + b.Navigation("CustomFields"); + + b.Navigation("GameSaves"); + + b.Navigation("Library") + .IsRequired(); + + b.Navigation("Media"); + + b.Navigation("PlaySessions"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.cs b/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.cs new file mode 100644 index 00000000..a4da5a78 --- /dev/null +++ b/LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Migrations +{ + /// + public partial class ChatReadStatusMessageId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ChatThreadReadStatuses_ThreadId", + table: "ChatThreadReadStatuses"); + + migrationBuilder.RenameColumn( + name: "LastReadOn", + table: "ChatThreadReadStatuses", + newName: "LastReadMessageId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_ChatThreadReadStatuses", + table: "ChatThreadReadStatuses", + columns: new[] { "ThreadId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_ChatThreadReadStatuses_LastReadMessageId", + table: "ChatThreadReadStatuses", + column: "LastReadMessageId"); + + migrationBuilder.AddForeignKey( + name: "FK_ChatThreadReadStatuses_ChatMessages_LastReadMessageId", + table: "ChatThreadReadStatuses", + column: "LastReadMessageId", + principalTable: "ChatMessages", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ChatThreadReadStatuses_ChatMessages_LastReadMessageId", + table: "ChatThreadReadStatuses"); + + migrationBuilder.DropPrimaryKey( + name: "PK_ChatThreadReadStatuses", + table: "ChatThreadReadStatuses"); + + migrationBuilder.DropIndex( + name: "IX_ChatThreadReadStatuses_LastReadMessageId", + table: "ChatThreadReadStatuses"); + + migrationBuilder.RenameColumn( + name: "LastReadMessageId", + table: "ChatThreadReadStatuses", + newName: "LastReadOn"); + + migrationBuilder.CreateIndex( + name: "IX_ChatThreadReadStatuses_ThreadId", + table: "ChatThreadReadStatuses", + column: "ThreadId"); + } + } +} diff --git a/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs index 8cde6d80..d26fd775 100644 --- a/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Server.Data.SQLite/Migrations/DatabaseContextModelSnapshot.cs @@ -378,16 +378,18 @@ namespace LANCommander.Migrations modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b => { - b.Property("LastReadOn") - .HasColumnType("TEXT"); - b.Property("ThreadId") .HasColumnType("TEXT"); b.Property("UserId") .HasColumnType("TEXT"); - b.HasIndex("ThreadId"); + b.Property("LastReadMessageId") + .HasColumnType("TEXT"); + + b.HasKey("ThreadId", "UserId"); + + b.HasIndex("LastReadMessageId"); b.HasIndex("UserId"); @@ -2093,6 +2095,11 @@ namespace LANCommander.Migrations modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b => { + b.HasOne("LANCommander.Server.Data.Models.ChatMessage", "LastReadMessage") + .WithMany() + .HasForeignKey("LastReadMessageId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("LANCommander.Server.Data.Models.ChatThread", "Thread") .WithMany() .HasForeignKey("ThreadId") @@ -2105,6 +2112,8 @@ namespace LANCommander.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("LastReadMessage"); + b.Navigation("Thread"); b.Navigation("User"); diff --git a/LANCommander.Server.Data/DatabaseContext.cs b/LANCommander.Server.Data/DatabaseContext.cs index 1846b44f..871f79df 100644 --- a/LANCommander.Server.Data/DatabaseContext.cs +++ b/LANCommander.Server.Data/DatabaseContext.cs @@ -459,15 +459,26 @@ namespace LANCommander.Server.Data #region Chat Relationships builder.Entity() - .HasNoKey(); + .HasKey(rs => new { rs.ThreadId, rs.UserId }); builder.Entity() .HasOne(rs => rs.Thread) - .WithMany(); + .WithMany() + .HasForeignKey(rs => rs.ThreadId) + .OnDelete(DeleteBehavior.Cascade); builder.Entity() .HasOne(rs => rs.User) - .WithMany(); + .WithMany() + .HasForeignKey(rs => rs.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(rs => rs.LastReadMessage) + .WithMany() + .HasForeignKey(rs => rs.LastReadMessageId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); #endregion } diff --git a/LANCommander.Server.Data/Models/ChatThreadReadStatus.cs b/LANCommander.Server.Data/Models/ChatThreadReadStatus.cs index cff61bef..e0f562dd 100644 --- a/LANCommander.Server.Data/Models/ChatThreadReadStatus.cs +++ b/LANCommander.Server.Data/Models/ChatThreadReadStatus.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; namespace LANCommander.Server.Data.Models; +[PrimaryKey(nameof(ThreadId), nameof(UserId))] public class ChatThreadReadStatus { public Guid ThreadId { get; set; } @@ -10,5 +12,7 @@ public class ChatThreadReadStatus public Guid UserId { get; set; } [ForeignKey(nameof(UserId))] public User? User { get; set; } - public DateTime? LastReadOn { get; set; } + public Guid? LastReadMessageId { get; set; } + [ForeignKey(nameof(LastReadMessageId))] + public ChatMessage? LastReadMessage { get; set; } } \ No newline at end of file diff --git a/LANCommander.Server.Services/Abstractions/IBaseDatabaseService.cs b/LANCommander.Server.Services/Abstractions/IBaseDatabaseService.cs index e00196e9..b166fc09 100644 --- a/LANCommander.Server.Services/Abstractions/IBaseDatabaseService.cs +++ b/LANCommander.Server.Services/Abstractions/IBaseDatabaseService.cs @@ -12,6 +12,7 @@ namespace LANCommander.Server.Services.Abstractions IBaseDatabaseService SortBy(Expression> expression, SortDirection direction = SortDirection.Ascending); IBaseDatabaseService AsNoTracking(); IBaseDatabaseService AsSplitQuery(); + Task AnyAsync(); Task> GetAsync(); diff --git a/LANCommander.Server.Services/ChatService.cs b/LANCommander.Server.Services/ChatService.cs index 98de9dd3..e37aab28 100644 --- a/LANCommander.Server.Services/ChatService.cs +++ b/LANCommander.Server.Services/ChatService.cs @@ -1,10 +1,13 @@ using System.Collections.Concurrent; using AutoMapper; -using LANCommander.Server.Data.Models; +using LANCommander.SDK.Models; using LANCommander.Server.Services.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; +using ChatMessage = LANCommander.Server.Data.Models.ChatMessage; +using ChatThread = LANCommander.Server.Data.Models.ChatThread; +using User = LANCommander.Server.Data.Models.User; namespace LANCommander.Server.Services { @@ -78,22 +81,82 @@ namespace LANCommander.Server.Services return message; } - public async Task> GetMessagesAsync(Guid threadId, int? count = 10, DateTime? createdBefore = null) + public async Task> GetMessagesAsync(Guid threadId, int? count = 10, Guid? cursor = null) { - if (createdBefore == null) - createdBefore = DateTime.UtcNow; + var pageSize = Math.Max(1, count.GetValueOrDefault(10)); + DateTime? createdBefore = null; + // If cursor is provided, use it to find the boundary for loading older messages + if (cursor.HasValue && cursor.Value != Guid.Empty) + { + var cursorMessage = await chatMessageService.GetAsync(cursor.Value); + + // Validate cursor message exists and belongs to this thread + if (cursorMessage != null && cursorMessage.ThreadId == threadId) + { + createdBefore = cursorMessage.CreatedOn; + } + else + { + // Invalid cursor - return empty result + logger.LogWarning("Invalid cursor message {CursorId} for thread {ThreadId}", cursor.Value, threadId); + return new InfiniteResponse + { + Items = [], + HasMore = false, + }; + } + } + + // Load messages older than the cursor (or newest messages if no cursor) var messages = await chatMessageService.Query(q => { - return q + var query = q .Include(m => m.CreatedBy) + .Where(m => m.ThreadId == threadId); + + // Apply cursor filter if provided + if (createdBefore.HasValue) + { + query = query.Where(m => m.CreatedOn < createdBefore.Value); + } + + return query .OrderByDescending(m => m.CreatedOn) - .Where(m => m.CreatedOn < createdBefore) - .Where(m => m.ThreadId == threadId) - .Take(count.GetValueOrDefault()); + .Take(pageSize); }).GetAsync(); - - return mapper.Map>(messages); + + var messageList = messages.ToList(); + bool hasMore = false; + + // Check if there are more older messages beyond what we just loaded + if (messageList.Count > 0) + { + // Use the oldest message in the batch as the boundary for checking if more exist + var oldestMessage = messageList[messageList.Count - 1]; + + hasMore = await chatMessageService.Query(q => + { + return q + .Where(m => m.ThreadId == threadId) + .Where(m => m.CreatedOn < oldestMessage.CreatedOn); + }).AnyAsync(); + } + else if (!createdBefore.HasValue) + { + // No cursor and no messages returned - check if any messages exist at all + hasMore = await chatMessageService.Query(q => + { + return q.Where(m => m.ThreadId == threadId); + }).AnyAsync(); + } + // If we had a cursor but got no results, there are no more older messages + + return new InfiniteResponse + { + Items = mapper.Map>(messageList), + HasMore = hasMore, + }; } public async Task GetThreadAsync(Guid threadId) @@ -151,12 +214,9 @@ namespace LANCommander.Server.Services public async Task GetUnreadMessageCountAsync(Guid threadId, Guid userId) { - var lastRead = await chatThreadReadStatusService.GetLastReadAsync(threadId, userId); + var lastReadMessageId = await chatThreadReadStatusService.GetLastReadMessageIdAsync(threadId, userId); - if (lastRead == null) - return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastRead.GetValueOrDefault()); - - return 0; + return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastReadMessageId); } } } diff --git a/LANCommander.Server.Services/ChatThreadReadStatusService.cs b/LANCommander.Server.Services/ChatThreadReadStatusService.cs index 67082457..aa1c6193 100644 --- a/LANCommander.Server.Services/ChatThreadReadStatusService.cs +++ b/LANCommander.Server.Services/ChatThreadReadStatusService.cs @@ -11,32 +11,40 @@ namespace LANCommander.Server.Services { using (var context = await contextFactory.CreateDbContextAsync()) { - if (context.ChatThreadReadStatuses != null) + if (context.ChatThreadReadStatuses != null && context.ChatMessages != null) { + // Get the latest message in the thread + var latestMessage = await context.ChatMessages + .Where(m => m.ThreadId == threadId) + .OrderByDescending(m => m.CreatedOn) + .FirstOrDefaultAsync(); + + if (latestMessage == null) + return; // No messages in thread, nothing to mark as read + var status = await context.ChatThreadReadStatuses.FirstOrDefaultAsync(rs => rs.ThreadId == threadId && rs.UserId == userId); if (status == null) { - var result = await context.ChatThreadReadStatuses.AddAsync(new ChatThreadReadStatus + await context.ChatThreadReadStatuses.AddAsync(new ChatThreadReadStatus { ThreadId = threadId, UserId = userId, - LastReadOn = DateTime.Now, + LastReadMessageId = latestMessage.Id, }); } else { - status.LastReadOn = DateTime.Now; - + status.LastReadMessageId = latestMessage.Id; context.ChatThreadReadStatuses.Update(status); } - } - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); + } } } - public async Task GetLastReadAsync(Guid threadId, Guid userId) + public async Task GetLastReadMessageIdAsync(Guid threadId, Guid userId) { using (var context = await contextFactory.CreateDbContextAsync()) { @@ -45,21 +53,42 @@ namespace LANCommander.Server.Services var result = await context.ChatThreadReadStatuses.FirstOrDefaultAsync(rs => rs.ThreadId == threadId && rs.UserId == userId); if (result != null) - return result.LastReadOn; + return result.LastReadMessageId; } } return null; } - public async Task GetUnreadCountAsync(Guid threadId, DateTime? lastReadOn) + public async Task GetUnreadCountAsync(Guid threadId, Guid? lastReadMessageId) { using (var context = await contextFactory.CreateDbContextAsync()) { if (context.ChatMessages != null) { + if (lastReadMessageId == null) + { + // No read status, count all messages in thread + return await context.ChatMessages + .Where(m => m.ThreadId == threadId) + .CountAsync(); + } + + // Get the last read message to find its CreatedOn timestamp + var lastReadMessage = await context.ChatMessages + .FirstOrDefaultAsync(m => m.Id == lastReadMessageId.Value); + + if (lastReadMessage == null) + { + // Last read message doesn't exist, count all messages + return await context.ChatMessages + .Where(m => m.ThreadId == threadId) + .CountAsync(); + } + + // Count messages created after the last read message return await context.ChatMessages - .Where(m => m.ThreadId == threadId && m.CreatedOn > lastReadOn) + .Where(m => m.ThreadId == threadId && m.CreatedOn > lastReadMessage.CreatedOn) .CountAsync(); } } diff --git a/LANCommander.Server.Services/UserService.cs b/LANCommander.Server.Services/UserService.cs index 61a25cf0..5cc88734 100644 --- a/LANCommander.Server.Services/UserService.cs +++ b/LANCommander.Server.Services/UserService.cs @@ -258,6 +258,29 @@ namespace LANCommander.Server.Services } + public virtual async Task AnyAsync() + { + try + { + using var context = await ContextFactory.CreateDbContextAsync(); + + var queryable = context.Set().AsQueryable(); + + foreach (var modifier in _modifiers) + queryable = modifier.Invoke(queryable); + + return await queryable.AnyAsync(); + } + catch + { + return false; + } + finally + { + Reset(); + } + } + public async Task> GetAsync() { try diff --git a/LANCommander.Server.Services/_BaseDatabaseService.cs b/LANCommander.Server.Services/_BaseDatabaseService.cs index 7dc9b990..633f3de4 100644 --- a/LANCommander.Server.Services/_BaseDatabaseService.cs +++ b/LANCommander.Server.Services/_BaseDatabaseService.cs @@ -96,6 +96,29 @@ namespace LANCommander.Server.Services } } + public virtual async Task AnyAsync() + { + try + { + using var context = await dbContextFactory.CreateDbContextAsync(); + + var queryable = context.Set().AsQueryable(); + + foreach (var modifier in _modifiers) + queryable = modifier.Invoke(queryable); + + return await queryable.AnyAsync(); + } + catch + { + return false; + } + finally + { + Reset(); + } + } + public virtual async Task> GetAsync() { return await GetAsync(x => true); diff --git a/LANCommander.Server/Clients/ServerChatClient.cs b/LANCommander.Server/Clients/ServerChatClient.cs index 2e74acda..b5051ebe 100644 --- a/LANCommander.Server/Clients/ServerChatClient.cs +++ b/LANCommander.Server/Clients/ServerChatClient.cs @@ -143,6 +143,11 @@ public class ServerChatClient( await chatService.UpdateReadStatus(threadId, user.Id); } + public async Task> GetMessagesAsync(Guid threadId, Guid? cursor, int count) + { + throw new NotImplementedException(); + } + public async Task GetMessagesAsync(Guid threadId) { // Ensure thread is loaded in cache diff --git a/LANCommander.Server/Hubs/ChatHub.cs b/LANCommander.Server/Hubs/ChatHub.cs index 004669eb..5cd470da 100644 --- a/LANCommander.Server/Hubs/ChatHub.cs +++ b/LANCommander.Server/Hubs/ChatHub.cs @@ -123,12 +123,8 @@ public class ChatHub( await Clients.Users(participants).ReceiveMessageAsync(threadId, mapper.Map(message)); } - public async Task> GetMessagesAsync(Guid threadId, ChatMessage? cursor, int? count) - { - var messages = await chatService.GetMessagesAsync(threadId, count, cursor?.SentOn.UtcDateTime ?? DateTime.UtcNow); - - return mapper.Map>(messages); - } + public async Task> GetMessagesAsync(Guid threadId, Guid? cursor, int? count) + => await chatService.GetMessagesAsync(threadId, count, cursor); public async Task StartTypingAsync(Guid threadId) { diff --git a/LANCommander.UI/Components/BaseComponent.cs b/LANCommander.UI/Components/BaseComponent.cs new file mode 100644 index 00000000..104660cb --- /dev/null +++ b/LANCommander.UI/Components/BaseComponent.cs @@ -0,0 +1,21 @@ +using AntDesign; +using LANCommander.UI.Providers; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace LANCommander.UI.Components; + +public class BaseComponent : ComponentBase +{ + [Inject] + protected IJSRuntime? JS { get; set; } + + [Inject] + protected ScriptProvider ScriptProvider { get; set; } + + [Inject] + protected NavigationManager NavigationManager { get; set; } + + [Inject] + protected IMessageService MessageService { get; set; } +} \ No newline at end of file diff --git a/LANCommander.UI/Components/Chat/ChatListThread.razor b/LANCommander.UI/Components/Chat/ChatListThread.razor index 2f50dc24..5036b762 100644 --- a/LANCommander.UI/Components/Chat/ChatListThread.razor +++ b/LANCommander.UI/Components/Chat/ChatListThread.razor @@ -1,6 +1,8 @@ @using LANCommander.SDK.Services +@using LANCommander.SDK.Models @namespace LANCommander.UI.Components @inject IChatClient ChatClient +@implements IAsyncDisposable @@ -38,20 +40,85 @@ int _unreadCount = 0; string _cssClass = string.Empty; + bool _isSubscribed = false; + + bool _previousActive = false; protected override async Task OnParametersSetAsync() { - _unreadCount = await ChatClient.GetUnreadMessageCountAsync(Thread.Id); + await RefreshUnreadCountAsync(); + + // Refresh unread count when thread becomes active (read status may have been updated) + if (Active && !_previousActive) + { + await RefreshUnreadCountAsync(); + } + _previousActive = Active; _cssClass = new ClassBuilder() .Add("chat-list-thread") .If(() => Active, "chat-list-thread-active") .Build(); + + // Subscribe to message events if not already subscribed + if (!_isSubscribed) + { + Thread.OnMessageReceivedAsync = OnMessageReceivedAsync; + Thread.OnMessagesReceivedAsync = OnMessagesReceivedAsync; + _isSubscribed = true; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Refresh unread count when component becomes active (read status may have changed) + if (Active && firstRender) + { + await RefreshUnreadCountAsync(); + } + } + + async Task OnMessageReceivedAsync(SDK.Models.ChatMessage message) + { + // Update unread count when a new message is received + await RefreshUnreadCountAsync(); + } + + async Task OnMessagesReceivedAsync(IEnumerable messages) + { + // Update unread count when messages are received + await RefreshUnreadCountAsync(); + } + + async Task RefreshUnreadCountAsync() + { + var newCount = await ChatClient.GetUnreadMessageCountAsync(Thread.Id); + + if (newCount != _unreadCount) + { + _unreadCount = newCount; + await InvokeAsync(StateHasChanged); + } } async Task OnClick(MouseEventArgs args) { if (OnSelected.HasDelegate) await OnSelected.InvokeAsync(Thread); + + // Refresh unread count when thread is selected (read status may have been updated) + await RefreshUnreadCountAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_isSubscribed) + { + Thread.OnMessageReceivedAsync -= OnMessageReceivedAsync; + Thread.OnMessagesReceivedAsync -= OnMessagesReceivedAsync; + _isSubscribed = false; + } + + await Task.CompletedTask; } } \ No newline at end of file diff --git a/LANCommander.UI/Components/Chat/ChatMessageInput.razor.scss b/LANCommander.UI/Components/Chat/ChatMessageInput.razor.scss index 0d758c8d..d3b33985 100644 --- a/LANCommander.UI/Components/Chat/ChatMessageInput.razor.scss +++ b/LANCommander.UI/Components/Chat/ChatMessageInput.razor.scss @@ -1,4 +1,6 @@ .chat-message-input { + flex: 0 0 auto; + .EasyMDEContainer .CodeMirror { margin: .5em; border: none; diff --git a/LANCommander.UI/Components/Chat/ChatThread.razor b/LANCommander.UI/Components/Chat/ChatThread.razor index c265875b..c0437b26 100644 --- a/LANCommander.UI/Components/Chat/ChatThread.razor +++ b/LANCommander.UI/Components/Chat/ChatThread.razor @@ -1,6 +1,6 @@ @using System.Collections.Specialized +@using LANCommander.SDK.Models @using LANCommander.SDK.Services -@using Microsoft.AspNetCore.Components.Web.Virtualization @inject IChatClient ChatClient @namespace LANCommander.UI.Components @@ -9,11 +9,19 @@ @if (_thread != null) { - + + @foreach (var message in context.Messages) + { + + } + } @@ -25,6 +33,7 @@ [Parameter] public Guid? Id { get; set; } SDK.Models.ChatThread? _thread; + private SDK.Models.ChatMessage? _lastCursor; protected override async Task OnParametersSetAsync() { @@ -41,15 +50,105 @@ void MessageGroupsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => InvokeAsync(StateHasChanged); - async Task> LoadMessages(SDK.Models.ChatMessage cursor, int pageSize) + async Task> LoadMessages(SDK.Models.ChatMessageGroup? cursor, int pageSize) { - var messages = await ChatClient.GetMessagesAsync(Id.Value, cursor, pageSize); - - return new InfiniteLoadResponse() + // Use the oldest message from the cursor group, or the last cursor message + SDK.Models.ChatMessage? messageCursor = null; + if (cursor != null && cursor.Messages.Count > 0) { - Items = messages, - HasMore = true, + // Get the oldest message from the cursor group (first message in chronological order) + messageCursor = cursor.Messages.OrderBy(m => m.SentOn).First(); + } + else if (_lastCursor != null) + { + messageCursor = _lastCursor; + } + + var response = await ChatClient.GetMessagesAsync(Id.Value, messageCursor?.Id ?? null, pageSize); + + // Filter out blank/whitespace messages + var validMessages = (response.Items ?? []) + .Where(m => !string.IsNullOrWhiteSpace(m.Content)) + .ToList(); + + // Mark messages as read when they are loaded + if (Id.HasValue && validMessages.Count > 0) + { + await ChatClient.UpdatedReadStatus(Id.Value); + } + + // Group messages by sender (messages are already in chronological order from the service) + var groups = GroupMessagesBySender(validMessages); + + // Track the oldest message for next cursor (from the oldest group) + // Groups are in newest-first order (will be reversed by InfiniteLoader) + // So the oldest group is the last one + if (groups.Count > 0) + { + var oldestGroup = groups[groups.Count - 1]; + if (oldestGroup.Messages.Count > 0) + { + // Get the oldest message from the oldest group + _lastCursor = oldestGroup.Messages.OrderBy(m => m.SentOn).First(); + } + } + + return new InfiniteResponse + { + Items = groups, + HasMore = response.HasMore }; } - + + private List GroupMessagesBySender(IEnumerable messages) + { + var groups = new List(); + SDK.Models.ChatMessageGroup? currentGroup = null; + + // Messages come from service in newest-first order (OrderByDescending) + // We'll process them in reverse to get oldest-first for grouping + var messagesList = messages.ToList(); + + // Reverse to get oldest-first order for proper grouping + messagesList.Reverse(); + + foreach (var message in messagesList) + { + // Start a new group if this is the first message, or if the sender changed + if (currentGroup == null || message.UserId != currentGroup.UserId) + { + if (currentGroup != null) + { + groups.Add(currentGroup); + } + + currentGroup = new SDK.Models.ChatMessageGroup + { + Id = Guid.NewGuid(), + UserId = message.UserId, + UserName = message.UserName, + Messages = new List { message } + }; + } + else + { + // Add to current group (messages within group are in chronological order) + currentGroup.Messages.Add(message); + } + } + + // Add the last group + if (currentGroup != null) + { + groups.Add(currentGroup); + } + + // Groups are now in oldest-first order (first group is oldest) + // InfiniteLoader will reverse them, so we need to reverse again to maintain oldest-first + // Actually, wait - InfiniteLoader reverses, so if we return oldest-first, after reversal it's newest-first (wrong) + // So we should return newest-first, then InfiniteLoader reverses to oldest-first + groups.Reverse(); + + return groups; + } } \ No newline at end of file diff --git a/LANCommander.UI/Components/Chat/ChatThread.razor.scss b/LANCommander.UI/Components/Chat/ChatThread.razor.scss index 658185dd..b03a7353 100644 --- a/LANCommander.UI/Components/Chat/ChatThread.razor.scss +++ b/LANCommander.UI/Components/Chat/ChatThread.razor.scss @@ -2,9 +2,23 @@ height: 100%; flex-grow: 1; background: #141414; + display: flex; + flex-direction: column; + min-height: 0; } .chat-thread-messages { - flex-grow: 1; + flex: 1 1 0; + min-height: 0; padding: 1em; + display: flex; + flex-direction: column; + overflow: hidden; + + .infinite-scroll { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + } } \ No newline at end of file diff --git a/LANCommander.UI/Components/ChunkUploader/ChunkUploader.razor b/LANCommander.UI/Components/ChunkUploader/ChunkUploader.razor index 3ca43d13..e157e47f 100644 --- a/LANCommander.UI/Components/ChunkUploader/ChunkUploader.razor +++ b/LANCommander.UI/Components/ChunkUploader/ChunkUploader.razor @@ -1,9 +1,9 @@ -@using LANCommander.UI.Extensions +@using LANCommander.UI.Providers @using Microsoft.AspNetCore.Components.Forms @using Microsoft.JSInterop @inject HttpClient HttpClient @inject NavigationManager Navigator -@inject IJSRuntime JS +@inject ScriptProvider ScriptProvider @inject IMessageService MessageService @implements IAsyncDisposable @namespace LANCommander.UI.Components @@ -104,7 +104,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) - _uploaderInterop ??= await JS.ImportModuleAsync(); + _uploaderInterop ??= await ScriptProvider.ImportModuleAsync(); } public async Task Start() diff --git a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor index 3b96ef2c..85e75a0b 100644 --- a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor +++ b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor @@ -1,4 +1,5 @@ @namespace LANCommander.UI.Components +@inherits BaseComponent @typeparam T
@@ -6,7 +7,7 @@ @foreach (var i in _items) { -
+
@ChildContent?.Invoke(i)
} diff --git a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs index 6e8f92d2..bd51df72 100644 --- a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs +++ b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs @@ -1,14 +1,14 @@ using System.Linq.Expressions; -using LANCommander.UI.Extensions; +using LANCommander.SDK.Models; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; namespace LANCommander.UI.Components; -public partial class InfiniteLoader : ComponentBase +public partial class InfiniteLoader : BaseComponent { [Parameter] - public Func>>? Loader { get; set; } + public Func>>? Loader { get; set; } [Parameter] public int PageSize { get; set; } = 10; @@ -19,9 +19,6 @@ public partial class InfiniteLoader : ComponentBase [Parameter] public RenderFragment? ChildContent { get; set; } - [Inject] - private IJSRuntime JS { get; set; } - private readonly List _items = new(); private T? _next; private bool _isLoadingMore; @@ -44,7 +41,58 @@ public partial class InfiniteLoader : ComponentBase protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) - _scrollInterop ??= await JS.ImportModuleAsync("InfiniteScroll", _scrollHost, _sentinel); + { + _scrollInterop ??= await ScriptProvider.ImportModuleAsync(this, "InfiniteScroll", _scrollHost, _sentinel); + + await _scrollInterop.InvokeVoidAsync("ObserveSentinel"); + + // Scroll to bottom to show newest messages + await Task.Delay(50); + await ScrollToBottomAsync(); + + // Check if sentinel is visible (content doesn't fill container) and load more if needed + // Use a small delay to ensure DOM is fully rendered + await Task.Delay(100); + await CheckAndLoadMoreIfNeededAsync(); + } + } + + private async Task ScrollToBottomAsync() + { + if (_scrollInterop == null) + return; + + try + { + await _scrollInterop.InvokeVoidAsync("ScrollToBottom"); + } + catch + { + // Ignore errors - method might not exist yet + } + } + + private async Task CheckAndLoadMoreIfNeededAsync() + { + if (_scrollInterop == null || _isLoadingMore || !_hasMore || Loader == null) + return; + + try + { + // Check if sentinel is visible (content doesn't fill viewport) + var isSentinelVisible = await _scrollInterop.InvokeAsync("IsSentinelVisible"); + + if (isSentinelVisible) + { + // Content doesn't fill container, load more messages + var anchor = await _scrollInterop.InvokeAsync("CaptureAnchor"); + await LoadMoreAsync(anchor); + } + } + catch + { + // Ignore errors - method might not exist yet or sentinel check failed + } } protected override void OnParametersSet() @@ -65,15 +113,42 @@ public partial class InfiniteLoader : ComponentBase _items.Clear(); if (response.Items is not null) - _items.AddRange(response.Items); + { + // Filter out duplicates using the key selector (in case SignalR already added some) + if (_keySelector is not null) + { + var seenKeys = new HashSet(); + var uniqueItems = response.Items.Where(item => + { + var key = _keySelector(item); + return seenKeys.Add(key); + }).ToList(); + + // Reverse the list so oldest messages are at the top and newest at the bottom + uniqueItems.Reverse(); + _items.AddRange(uniqueItems); + } + else + { + var itemsList = response.Items.ToList(); + // Reverse the list so oldest messages are at the top and newest at the bottom + itemsList.Reverse(); + _items.AddRange(itemsList); + } + } _hasMore = response.HasMore; - _next = response.Next; + + // Set cursor to the oldest message (first in list after reversal) for loading older messages + if (_items.Count > 0) + { + _next = _items[0]; + } } private async Task LoadMoreAsync(object? anchor) { - if (_isLoadingMore || !_hasMore || Loader is null || _next is null || _scrollInterop is null) + if (_isLoadingMore || !_hasMore || Loader is null || _scrollInterop is null) return; _isLoadingMore = true; @@ -82,15 +157,47 @@ public partial class InfiniteLoader : ComponentBase { var page = await Loader.Invoke(_next, PageSize); - if (page.Items is not null) - _items.InsertRange(0, page.Items); + if (page.Items is not null && _keySelector is not null) + { + // Filter out duplicates using the key selector + var existingKeys = new HashSet(_items.Select(_keySelector)); + var newItems = page.Items.Where(item => !existingKeys.Contains(_keySelector(item))).ToList(); + + if (newItems.Count > 0) + { + // Reverse new items so oldest is first, then insert at the beginning + newItems.Reverse(); + _items.InsertRange(0, newItems); + + // Update cursor to the oldest message (first in list) for next load + _next = _items[0]; + } + } + else if (page.Items is not null) + { + var itemsList = page.Items.ToList(); + // Reverse new items so oldest is first, then insert at the beginning + itemsList.Reverse(); + _items.InsertRange(0, itemsList); + + // Update cursor to the oldest message (first in list) for next load + if (_items.Count > 0) + { + _next = _items[0]; + } + } _hasMore = page.HasMore; - _next = page.Next; await InvokeAsync(StateHasChanged); await _scrollInterop.InvokeVoidAsync("RestoreAfterPrepend", anchor); + + _isLoadingMore = false; + + // After loading and rendering, check if we need to load more to fill the viewport + await Task.Delay(50); + await CheckAndLoadMoreIfNeededAsync(); } finally { @@ -98,7 +205,7 @@ public partial class InfiniteLoader : ComponentBase } } - [JSInvokable] + [JSInvokable("OnSentinelVisible")] public async Task OnSentinelVisible() { if (_scrollInterop is null) diff --git a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.scss b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.scss new file mode 100644 index 00000000..5a058be4 --- /dev/null +++ b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.scss @@ -0,0 +1,14 @@ +.infinite-scroll { + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: end; + overflow-y: auto; + overflow-x: hidden; +} + +.sentinel { + height: 1px; + width: 100%; + flex-shrink: 0; +} \ No newline at end of file diff --git a/LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts b/LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts index be45dc9a..c321fe47 100644 --- a/LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts +++ b/LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts @@ -1,15 +1,16 @@ import { InfiniteScrollAnchor } from "./InfiniteScrollAnchor"; -declare const DotNet: typeof import("@microsoft/dotnet-js-interop").DotNet; export class InfiniteScroll { public scrollHost: Element; public sentinel: Element; + public dotnet: any; - public static Create(scrollHostSelector: string, sentinelSelector: string): InfiniteScroll { - return new InfiniteScroll(document.querySelector(scrollHostSelector), document.querySelector(sentinelSelector)); + public static Create(dotnet: any, scrollHost: Element, sentinel: Element): InfiniteScroll { + return new InfiniteScroll(dotnet, scrollHost, sentinel); } - constructor(scrollHost: Element, sentinel: Element) { + constructor(dotnet: any, scrollHost: Element, sentinel: Element) { + this.dotnet = dotnet; this.scrollHost = scrollHost; this.sentinel = sentinel; } @@ -18,7 +19,7 @@ export class InfiniteScroll { const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { - DotNet.invokeMethodAsync("LANCommander.UI", "OnSentinelVisible"); + this.dotnet.invokeMethodAsync("OnSentinelVisible"); } } }, { @@ -61,4 +62,16 @@ export class InfiniteScroll { this.scrollHost.scrollTop += (newOffset - anchor.OffsetTop); } + + IsSentinelVisible(): boolean { + const rect = this.sentinel.getBoundingClientRect(); + const hostRect = this.scrollHost.getBoundingClientRect(); + + // Check if sentinel is within the visible area of the scroll host + return rect.top >= hostRect.top && rect.top <= hostRect.bottom; + } + + ScrollToBottom() { + this.scrollHost.scrollTop = this.scrollHost.scrollHeight; + } } \ No newline at end of file diff --git a/LANCommander.UI/Components/LocalTime/LocalTimeInitializer.razor b/LANCommander.UI/Components/LocalTime/LocalTimeInitializer.razor index 48b202b6..a54aa462 100644 --- a/LANCommander.UI/Components/LocalTime/LocalTimeInitializer.razor +++ b/LANCommander.UI/Components/LocalTime/LocalTimeInitializer.razor @@ -2,6 +2,7 @@ @using LANCommander.UI.Providers @using Microsoft.JSInterop @inject TimeProvider TimeProvider +@inject ScriptProvider ScriptProvider @inject IJSRuntime JS @namespace LANCommander.UI.Components @@ -14,7 +15,7 @@ { try { - _timeProvider ??= await JS.ImportModuleAsync("TimeProvider"); + _timeProvider ??= await ScriptProvider.ImportModuleAsync("TimeProvider"); var timeZone = await _timeProvider.InvokeAsync("GetLocalTimeZone"); diff --git a/LANCommander.UI/Components/SplitPane/SplitPane.razor b/LANCommander.UI/Components/SplitPane/SplitPane.razor index 01c00c56..e5a4fa0e 100644 --- a/LANCommander.UI/Components/SplitPane/SplitPane.razor +++ b/LANCommander.UI/Components/SplitPane/SplitPane.razor @@ -1,6 +1,7 @@ @using LANCommander.UI.Extensions +@using LANCommander.UI.Providers @using Microsoft.JSInterop -@inject IJSRuntime JS +@inject ScriptProvider ScriptProvider @namespace LANCommander.UI.Components @@ -20,6 +21,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) - _splitPane ??= await JS.ImportModuleAsync(_paneId); + _splitPane ??= await ScriptProvider.ImportModuleAsync(_paneId); } } \ No newline at end of file diff --git a/LANCommander.UI/Components/Terminal/Terminal.cs b/LANCommander.UI/Components/Terminal/Terminal.cs index b3f72e27..db16e6da 100644 --- a/LANCommander.UI/Components/Terminal/Terminal.cs +++ b/LANCommander.UI/Components/Terminal/Terminal.cs @@ -1,4 +1,5 @@ using LANCommander.UI.Extensions; +using LANCommander.UI.Providers; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using XtermBlazor; @@ -9,7 +10,7 @@ namespace LANCommander.UI.Components; public partial class Terminal : Xterm { [Inject] - IJSRuntime JS { get; set; } + ScriptProvider ScriptProvider { get; set; } IJSObjectReference? _interop; @@ -17,7 +18,7 @@ public partial class Terminal : Xterm { if (firstRender) { - _interop ??= await JS.ImportModuleAsync(); + _interop ??= await ScriptProvider.ImportModuleAsync(); if (Addons == null) Addons = new HashSet(); diff --git a/LANCommander.UI/Components/Terminal/Terminal.ts b/LANCommander.UI/Components/Terminal/Terminal.ts index c8e74033..9074a2f2 100644 --- a/LANCommander.UI/Components/Terminal/Terminal.ts +++ b/LANCommander.UI/Components/Terminal/Terminal.ts @@ -7,7 +7,6 @@ export class Terminal { } constructor() { - console.log("Terminal Constructor"); (window).XtermBlazor.registerAddons({ "readline": new Readline(), "addon-fit": new FitAddon(), diff --git a/LANCommander.UI/Extensions/IJSRuntimeExtensions.cs b/LANCommander.UI/Extensions/IJSRuntimeExtensions.cs deleted file mode 100644 index 79dd5b2a..00000000 --- a/LANCommander.UI/Extensions/IJSRuntimeExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.JSInterop; - -namespace LANCommander.UI.Extensions; - -public static class IJSRuntimeExtensions -{ - private const string ScriptAsset = "./_content/LANCommander.UI/bundle.js"; - - private static IJSObjectReference? _module; - - public static async Task InitializeAsync(this IJSRuntime js) - => _module ??= await js.InvokeAsync("import", ScriptAsset); - - public static async Task ImportModuleAsync(this IJSRuntime js, params object[] args) - { - var type = typeof(T); - - return await js.ImportModuleAsync(type.Name, args); - } - - public static async Task ImportModuleAsync(this IJSRuntime js, string type, params object[] args) - { - await js.InitializeAsync(); - - return await _module!.InvokeAsync($"{type}.Create", args); - } -} \ No newline at end of file diff --git a/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs b/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs index 0dac03fa..362059bf 100644 --- a/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs +++ b/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ public static class IServiceCollectionExtensions public static IServiceCollection AddLANCommanderUI(this IServiceCollection services) { services.AddSingleton(); + services.AddScoped(); return services; } diff --git a/LANCommander.UI/Providers/ScriptProvider.cs b/LANCommander.UI/Providers/ScriptProvider.cs new file mode 100644 index 00000000..7d4ad754 --- /dev/null +++ b/LANCommander.UI/Providers/ScriptProvider.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace LANCommander.UI.Providers; + +public class ScriptProvider(IJSRuntime js) +{ + private const string ScriptAsset = "./_content/LANCommander.UI/bundle.js"; + + private IJSObjectReference? _module; + + public async Task InitializeAsync() + => _module ??= await js.InvokeAsync("import", ScriptAsset); + + public async Task ImportModuleAsync(TComponent component, params object[] args) where TComponent : class, IComponent + { + var mergedArgs = new object[args.Length + 1]; + + mergedArgs[0] = DotNetObjectReference.Create(component); + Array.Copy(args, 0, mergedArgs, 1, args.Length); + + return await ImportModuleAsync(args); + } + + public async Task ImportModuleAsync(TComponent component, string moduleName, + params object[] args) where TComponent : class, IComponent + { + var mergedArgs = new object[args.Length + 1]; + + mergedArgs[0] = DotNetObjectReference.Create(component); + Array.Copy(args, 0, mergedArgs, 1, args.Length); + + return await ImportModuleAsync(moduleName, mergedArgs); + } + + public async Task ImportModuleAsync(params object[] args) + { + var type = typeof(T); + + return await ImportModuleAsync(type.Name, args); + } + + public async Task ImportModuleAsync(string moduleName, params object[] args) + { + await InitializeAsync(); + + return await _module!.InvokeAsync($"{moduleName}.Create", args); + } + + public async Task InvokeVoidAsync(string method, params object[] args) + { + await InitializeAsync(); + + await _module!.InvokeVoidAsync(method, args); + } + + public async Task InvokeAsync(string method, params object[] args) + { + await InitializeAsync(); + + return await _module!.InvokeAsync(method, args); + } +} \ No newline at end of file diff --git a/LANCommander.UI/main.js b/LANCommander.UI/_Imports.razor.js similarity index 100% rename from LANCommander.UI/main.js rename to LANCommander.UI/_Imports.razor.js diff --git a/LANCommander.UI/Main.ts b/LANCommander.UI/_Imports.razor.ts similarity index 100% rename from LANCommander.UI/Main.ts rename to LANCommander.UI/_Imports.razor.ts diff --git a/LANCommander.UI/webpack.config.js b/LANCommander.UI/webpack.config.js index 3ed8d982..e2c97292 100644 --- a/LANCommander.UI/webpack.config.js +++ b/LANCommander.UI/webpack.config.js @@ -3,7 +3,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { - entry: ['./Main.ts'], // Adjust the path if your index.js is located elsewhere + entry: ['./_Imports.razor.ts'], // Adjust the path if your index.js is located elsewhere module: { rules: [ {