diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 7bc001faf..5752f2273 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -123,30 +123,32 @@ public class ReadingListServiceTests public async Task AddChaptersToReadingList_ShouldAddFirstItem_AsOrderZero() { await ResetDb(); - _context.AppUser.Add(new AppUserBuilder("majora2007", "") - .WithLibrary(new LibraryBuilder("Test LIb", LibraryType.Book) - .WithSeries(new SeriesBuilder("Test") - .WithMetadata(new SeriesMetadataBuilder().Build()) - .WithVolumes(new List() - { - new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("1") - .WithAgeRating(AgeRating.Everyone) - .Build() - ) - .WithChapter(new ChapterBuilder("2") - .WithAgeRating(AgeRating.X18Plus) - .Build() - ) - .WithChapter(new ChapterBuilder("3") - .WithAgeRating(AgeRating.X18Plus) - .Build() - ) + var library = new LibraryBuilder("Test Lib", LibraryType.Book) + .WithSeries(new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes(new List() + { + new VolumeBuilder("0") + .WithChapter(new ChapterBuilder("1") + .WithAgeRating(AgeRating.Everyone) .Build() - }) - .Build()) - .Build() - ) + ) + .WithChapter(new ChapterBuilder("2") + .WithAgeRating(AgeRating.X18Plus) + .Build() + ) + .WithChapter(new ChapterBuilder("3") + .WithAgeRating(AgeRating.X18Plus) + .Build() + ) + .Build() + }) + .Build()) + .Build(); + await _context.SaveChangesAsync(); + + _context.AppUser.Add(new AppUserBuilder("majora2007", "") + .WithLibrary(library) .Build() ); @@ -763,16 +765,17 @@ public class ReadingListServiceTests .Build() ); + // NOTE: WithLibrary creates a SideNavStream hence why we need to use the same instance for multiple users to avoid an id conflict + var library = new LibraryBuilder("Test LIb 2", LibraryType.Book) + .WithSeries(fablesSeries) + .Build(); + _context.AppUser.Add(new AppUserBuilder("majora2007", string.Empty) - .WithLibrary(new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .Build()) + .WithLibrary(library) .Build() ); _context.AppUser.Add(new AppUserBuilder("admin", string.Empty) - .WithLibrary(new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .Build()) + .WithLibrary(library) .Build() ); await _unitOfWork.CommitAsync(); diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 8e2df09fe..5e943441d 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -8,7 +8,6 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; -using API.DTOs.Dashboard; using API.DTOs.Email; using API.Entities; using API.Entities.Enums; @@ -1046,123 +1045,4 @@ public class AccountController : BaseApiController return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey); } - - /// - /// Returns the layout of the user's dashboard - /// - /// - [HttpGet("dashboard")] - public async Task>> GetDashboardLayout(bool visibleOnly = true) - { - var streams = await _unitOfWork.UserRepository.GetDashboardStreams(User.GetUserId(), visibleOnly); - return Ok(streams); - } - - /// - /// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible - /// - /// - /// - [HttpPost("add-dashboard-stream")] - public async Task> AddDashboard([FromQuery] int smartFilterId) - { - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.DashboardStreams); - if (user == null) return Unauthorized(); - - var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId); - if (smartFilter == null) return NoContent(); - - var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId); - if (stream != null) return BadRequest("There is an existing stream with this Filter"); - - var maxOrder = user!.DashboardStreams.Max(d => d.Order); - var createdStream = new AppUserDashboardStream() - { - Name = smartFilter.Name, - IsProvided = false, - StreamType = DashboardStreamType.SmartFilter, - Visible = true, - Order = maxOrder + 1, - SmartFilter = smartFilter - }; - - user.DashboardStreams.Add(createdStream); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); - - var ret = new DashboardStreamDto() - { - Name = createdStream.Name, - IsProvided = createdStream.IsProvided, - Visible = createdStream.Visible, - Order = createdStream.Order, - SmartFilterEncoded = smartFilter.Filter, - StreamType = createdStream.StreamType - }; - - - await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), - User.GetUserId()); - return Ok(ret); - } - - /// - /// Updates the visibility of a dashboard stream - /// - /// - /// - [HttpPost("update-dashboard-stream")] - public async Task UpdateDashboardStream(DashboardStreamDto dto) - { - var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id); - if (stream == null) return BadRequest(); - stream.Visible = dto.Visible; - - _unitOfWork.UserRepository.Update(stream); - await _unitOfWork.CommitAsync(); - var userId = User.GetUserId(); - await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId), - userId); - return Ok(); - } - - /// - /// Updates the position of a dashboard stream - /// - /// - /// - [HttpPost("update-dashboard-position")] - public async Task UpdateDashboardStreamPosition(UpdateDashboardStreamPositionDto dto) - { - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), - AppUserIncludes.DashboardStreams); - var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.DashboardStreamId); - if (stream == null) return BadRequest(); - if (stream.Order == dto.ToPosition) return Ok(); - - var list = user!.DashboardStreams.ToList(); - ReorderItems(list, stream.Id, dto.ToPosition); - user.DashboardStreams = list; - - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), - user.Id); - return Ok(); - } - - private static void ReorderItems(List items, int itemId, int toPosition) - { - var item = items.Find(r => r.Id == itemId); - if (item != null) - { - items.Remove(item); - items.Insert(toPosition, item); - } - - for (var i = 0; i < items.Count; i++) - { - items[i].Order = i; - } - } } diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index 6a2d06ee5..d997aa932 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -87,6 +87,10 @@ public class FilterController : BaseApiController // This needs to delete any dashboard filters that have it too var streams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(filter.Id); _unitOfWork.UserRepository.Delete(streams); + + var streams2 = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(filter.Id); + _unitOfWork.UserRepository.Delete(streams2); + _unitOfWork.AppUserSmartFilterRepository.Delete(filter); await _unitOfWork.CommitAsync(); return Ok(); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index fc3965577..f80d996e7 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -21,6 +21,7 @@ using AutoMapper; using EasyCaching.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -97,6 +98,27 @@ public class LibraryController : BaseApiController admin.Libraries.Add(library); } + var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList(); + + var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams)) + .Where(u => userIds.Contains(u.Id)) + .ToList(); + + foreach (var user in userNeedingNewLibrary) + { + var maxCount = user.SideNavStreams.Select(s => s.Order).Max(); + user.SideNavStreams.Add(new AppUserSideNavStream() + { + Name = library.Name, + Order = maxCount + 1, + IsProvided = false, + StreamType = SideNavStreamType.Library, + LibraryId = library.Id, + Visible = true, + }); + _unitOfWork.UserRepository.Update(user); + } + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); @@ -105,6 +127,8 @@ public class LibraryController : BaseApiController _taskScheduler.ScanLibrary(library.Id); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); + await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, + MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); return Ok(); } @@ -329,9 +353,15 @@ public class LibraryController : BaseApiController _unitOfWork.LibraryRepository.Delete(library); + var streams = await _unitOfWork.UserRepository.GetSideNavStreamsByLibraryId(library.Id); + _unitOfWork.UserRepository.Delete(streams); + + await _unitOfWork.CommitAsync(); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, + MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); if (chapterIds.Any()) { diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 8e1e6027f..7ed88fe7d 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -50,4 +50,18 @@ public class PluginController : BaseApiController KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value }; } + + /// + /// Returns the version of the Kavita install + /// + /// Required for authenticating to get result + /// + [AllowAnonymous] + [HttpGet("version")] + public async Task> GetVersion([Required] string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId <= 0) return Unauthorized(); + return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value); + } } diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs new file mode 100644 index 000000000..9c6da5a97 --- /dev/null +++ b/API/Controllers/StreamController.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Dashboard; +using API.DTOs.SideNav; +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers; + +/// +/// Responsible for anything that deals with Streams (SmartFilters, ExternalSource, DashboardStream, SideNavStream) +/// +public class StreamController : BaseApiController +{ + private readonly IStreamService _streamService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILogger logger) + { + _streamService = streamService; + _unitOfWork = unitOfWork; + _logger = logger; + } + + /// + /// Returns the layout of the user's dashboard + /// + /// + [HttpGet("dashboard")] + public async Task>> GetDashboardLayout(bool visibleOnly = true) + { + return Ok(await _streamService.GetDashboardStreams(User.GetUserId(), visibleOnly)); + } + + /// + /// Return's the user's side nav + /// + [HttpGet("sidenav")] + public async Task>> GetSideNav(bool visibleOnly = true) + { + return Ok(await _streamService.GetSidenavStreams(User.GetUserId(), visibleOnly)); + } + + /// + /// Return's the user's external sources + /// + [HttpGet("external-sources")] + public async Task>> GetExternalSources() + { + return Ok(await _streamService.GetExternalSources(User.GetUserId())); + } + + /// + /// Create an external Source + /// + /// + /// + [HttpPost("create-external-source")] + public async Task> CreateExternalSource(ExternalSourceDto dto) + { + // Check if a host and api key exists for the current user + return Ok(await _streamService.CreateExternalSource(User.GetUserId(), dto)); + } + + /// + /// Updates an existing external source + /// + /// + /// + [HttpPost("update-external-source")] + public async Task> UpdateExternalSource(ExternalSourceDto dto) + { + // Check if a host and api key exists for the current user + return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto)); + } + + /// + /// Validates the external source by host is unique (for this user) + /// + /// + /// + [HttpGet("external-source-exists")] + public async Task> ExternalSourceExists(string host, string name, string apiKey) + { + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey)); + } + + /// + /// Delete's the external source + /// + /// + /// + [HttpDelete("delete-external-source")] + public async Task ExternalSourceExists(int externalSourceId) + { + await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId); + return Ok(); + } + + + /// + /// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible + /// + /// + /// + [HttpPost("add-dashboard-stream")] + public async Task> AddDashboard([FromQuery] int smartFilterId) + { + return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId)); + } + + /// + /// Updates the visibility of a dashboard stream + /// + /// + /// + [HttpPost("update-dashboard-stream")] + public async Task UpdateDashboardStream(DashboardStreamDto dto) + { + await _streamService.UpdateDashboardStream(User.GetUserId(), dto); + return Ok(); + } + + /// + /// Updates the position of a dashboard stream + /// + /// + /// + [HttpPost("update-dashboard-position")] + public async Task UpdateDashboardStreamPosition(UpdateStreamPositionDto dto) + { + await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto); + return Ok(); + } + + + /// + /// Creates a SideNav Stream from a SmartFilter and adds it to the user's sidenav as visible + /// + /// + /// + [HttpPost("add-sidenav-stream")] + public async Task> AddSideNav([FromQuery] int smartFilterId) + { + return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId)); + } + + /// + /// Creates a SideNav Stream from a SmartFilter and adds it to the user's sidenav as visible + /// + /// + /// + [HttpPost("add-sidenav-stream-from-external-source")] + public async Task> AddSideNavFromExternalSource([FromQuery] int externalSourceId) + { + return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId)); + } + + /// + /// Updates the visibility of a dashboard stream + /// + /// + /// + [HttpPost("update-sidenav-stream")] + public async Task UpdateSideNavStream(SideNavStreamDto dto) + { + await _streamService.UpdateSideNavStream(User.GetUserId(), dto); + return Ok(); + } + + /// + /// Updates the position of a dashboard stream + /// + /// + /// + [HttpPost("update-sidenav-position")] + public async Task UpdateSideNavStreamPosition(UpdateStreamPositionDto dto) + { + await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto); + return Ok(); + } +} diff --git a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs new file mode 100644 index 000000000..f9005a585 --- /dev/null +++ b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Dashboard; + +public class UpdateStreamPositionDto +{ + public int FromPosition { get; set; } + public int ToPosition { get; set; } + public int Id { get; set; } + public string StreamName { get; set; } +} diff --git a/API/DTOs/SideNav/ExternalSourceDto.cs b/API/DTOs/SideNav/ExternalSourceDto.cs new file mode 100644 index 000000000..e9ae03066 --- /dev/null +++ b/API/DTOs/SideNav/ExternalSourceDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace API.DTOs.SideNav; + +public class ExternalSourceDto +{ + public required int Id { get; set; } = 0; + public required string Name { get; set; } + public required string Host { get; set; } + public required string ApiKey { get; set; } +} diff --git a/API/DTOs/SideNav/SideNavStreamDto.cs b/API/DTOs/SideNav/SideNavStreamDto.cs new file mode 100644 index 000000000..1f3453611 --- /dev/null +++ b/API/DTOs/SideNav/SideNavStreamDto.cs @@ -0,0 +1,39 @@ +using API.Entities; +using API.Entities.Enums; + +namespace API.DTOs.SideNav; + +public class SideNavStreamDto +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// Is System Provided + /// + public bool IsProvided { get; set; } + /// + /// Sort Order on the Dashboard + /// + public int Order { get; set; } + /// + /// If Not IsProvided, the appropriate smart filter + /// + /// Encoded filter + public string? SmartFilterEncoded { get; set; } + public int? SmartFilterId { get; set; } + /// + /// External Source Url if configured + /// + public int ExternalSourceId { get; set; } + public ExternalSourceDto? ExternalSource { get; set; } + /// + /// For system provided + /// + public SideNavStreamType StreamType { get; set; } + public bool Visible { get; set; } + public int? LibraryId { get; set; } + /// + /// Only available for SideNavStreamType.Library + /// + public LibraryDto? Library { get; set; } +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index b5e6abaa0..c20e84d2a 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -56,6 +56,8 @@ public sealed class DataContext : IdentityDbContext AppUserTableOfContent { get; set; } = null!; public DbSet AppUserSmartFilter { get; set; } = null!; public DbSet AppUserDashboardStream { get; set; } = null!; + public DbSet AppUserSideNavStream { get; set; } = null!; + public DbSet AppUserExternalSource { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) @@ -128,6 +130,13 @@ public sealed class DataContext : IdentityDbContext() .HasIndex(e => e.Visible) .IsUnique(false); + + builder.Entity() + .Property(b => b.StreamType) + .HasDefaultValue(SideNavStreamType.SmartFilter); + builder.Entity() + .HasIndex(e => e.Visible) + .IsUnique(false); } diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs new file mode 100644 index 000000000..d4220e7f7 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// Introduced in v0.7.8.7 and v0.7.9, this adds SideNavStream's for all Libraries a User has access to +/// +public static class MigrateUserLibrarySideNavStream +{ + public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger) + { + logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Please be patient, this may take some time. This is not an error"); + + var usersWithLibraryStreams = await dataContext.AppUser.Include(u => u.SideNavStreams) + .AnyAsync(u => u.SideNavStreams.Count > 0 && u.SideNavStreams.Any(s => s.LibraryId > 0)); + + if (usersWithLibraryStreams) + { + logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - complete. Nothing to do"); + return; + } + + var users = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams); + foreach (var user in users) + { + var userLibraries = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id); + foreach (var lib in userLibraries) + { + var prevMaxOrder = user.SideNavStreams.Max(s => s.Order); + user.SideNavStreams.Add(new AppUserSideNavStream() + { + Name = lib.Name, + LibraryId = lib.Id, + IsProvided = false, + Visible = true, + StreamType = SideNavStreamType.Library, + Order = prevMaxOrder + 1 + }); + } + unitOfWork.UserRepository.Update(user); + } + + await unitOfWork.CommitAsync(); + + logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.Designer.cs b/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.Designer.cs new file mode 100644 index 000000000..708bcb46e --- /dev/null +++ b/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.Designer.cs @@ -0,0 +1,2472 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20231013194957_SideNavStreamAndExternalSource")] + partial class SideNavStreamAndExternalSource + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + 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("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.cs b/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.cs new file mode 100644 index 000000000..b8dd6111e --- /dev/null +++ b/API/Data/Migrations/20231013194957_SideNavStreamAndExternalSource.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SideNavStreamAndExternalSource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserExternalSource", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + Host = table.Column(type: "TEXT", nullable: true), + ApiKey = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserExternalSource", x => x.Id); + table.ForeignKey( + name: "FK_AppUserExternalSource_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppUserSideNavStream", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + IsProvided = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + LibraryId = table.Column(type: "INTEGER", nullable: true), + ExternalSourceId = table.Column(type: "INTEGER", nullable: true), + StreamType = table.Column(type: "INTEGER", nullable: false, defaultValue: 5), + Visible = table.Column(type: "INTEGER", nullable: false), + SmartFilterId = table.Column(type: "INTEGER", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserSideNavStream", x => x.Id); + table.ForeignKey( + name: "FK_AppUserSideNavStream_AppUserSmartFilter_SmartFilterId", + column: x => x.SmartFilterId, + principalTable: "AppUserSmartFilter", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_AppUserSideNavStream_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserExternalSource_AppUserId", + table: "AppUserExternalSource", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserSideNavStream_AppUserId", + table: "AppUserSideNavStream", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserSideNavStream_SmartFilterId", + table: "AppUserSideNavStream", + column: "SmartFilterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserSideNavStream_Visible", + table: "AppUserSideNavStream", + column: "Visible"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserExternalSource"); + + migrationBuilder.DropTable( + name: "AppUserSideNavStream"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 0856e4901..65e12c0a8 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.10"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -223,6 +223,31 @@ namespace API.Data.Migrations b.ToTable("AppUserDashboardStream"); }); + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => { b.Property("Id") @@ -456,6 +481,52 @@ namespace API.Data.Migrations b.ToTable("AspNetUserRoles", (string)null); }); + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => { b.Property("Id") @@ -1790,6 +1861,17 @@ namespace API.Data.Migrations b.Navigation("SmartFilter"); }); + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -1887,6 +1969,23 @@ namespace API.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2303,6 +2402,8 @@ namespace API.Data.Migrations b.Navigation("Devices"); + b.Navigation("ExternalSources"); + b.Navigation("Progresses"); b.Navigation("Ratings"); @@ -2311,6 +2412,8 @@ namespace API.Data.Migrations b.Navigation("ScrobbleHolds"); + b.Navigation("SideNavStreams"); + b.Navigation("SmartFilters"); b.Navigation("TableOfContents"); diff --git a/API/Data/Repositories/AppUserExternalSourceRepository.cs b/API/Data/Repositories/AppUserExternalSourceRepository.cs new file mode 100644 index 000000000..6d0dcd046 --- /dev/null +++ b/API/Data/Repositories/AppUserExternalSourceRepository.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.SideNav; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Kavita.Common.Helpers; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IAppUserExternalSourceRepository +{ + void Update(AppUserExternalSource source); + void Delete(AppUserExternalSource source); + Task GetById(int externalSourceId); + Task> GetExternalSources(int userId); + Task ExternalSourceExists(int userId, string name, string host, string apiKey); +} + +public class AppUserExternalSourceRepository : IAppUserExternalSourceRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public AppUserExternalSourceRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(AppUserExternalSource source) + { + _context.Entry(source).State = EntityState.Modified; + } + + public void Delete(AppUserExternalSource source) + { + _context.AppUserExternalSource.Remove(source); + } + + public async Task GetById(int externalSourceId) + { + return await _context.AppUserExternalSource + .Where(s => s.Id == externalSourceId) + .FirstOrDefaultAsync(); + } + + public async Task> GetExternalSources(int userId) + { + return await _context.AppUserExternalSource.Where(s => s.AppUserId == userId) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + /// + /// Checks if all the properties match exactly. This will allow a user to setup 2 External Sources with different Users + /// + /// + /// + /// + /// + /// + public async Task ExternalSourceExists(int userId, string name, string host, string apiKey) + { + host = host.Trim(); + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(apiKey)) return false; + var hostWithEndingSlash = UrlHelper.EnsureEndsWithSlash(host)!; + return await _context.AppUserExternalSource + .Where(s => s.AppUserId == userId ) + .Where(s => s.Host.ToUpper().Equals(hostWithEndingSlash.ToUpper()) + && s.Name.ToUpper().Equals(name.ToUpper()) + && s.ApiKey.Equals(apiKey)) + .AnyAsync(); + } +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 76135444d..ab6e1a003 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,11 +10,11 @@ using API.DTOs.Filtering.v2; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; +using API.DTOs.SideNav; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; -using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; @@ -37,7 +36,9 @@ public enum AppUserIncludes Devices = 256, ScrobbleHolds = 512, SmartFilters = 1024, - DashboardStreams = 2048 + DashboardStreams = 2048, + SideNavStreams = 4096, + ExternalSources = 8192 // 2^13 } public interface IUserRepository @@ -46,10 +47,12 @@ public interface IUserRepository void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); void Update(AppUserDashboardStream stream); + void Update(AppUserSideNavStream stream); void Add(AppUserBookmark bookmark); void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); - void Delete(IList streams); + void Delete(IEnumerable streams); + void Delete(IEnumerable streams); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); @@ -83,6 +86,11 @@ public interface IUserRepository Task> GetDashboardStreams(int userId, bool visibleOnly = false); Task GetDashboardStream(int streamId); Task> GetDashboardStreamWithFilter(int filterId); + Task> GetSideNavStreams(int userId, bool visibleOnly = false); + Task GetSideNavStream(int streamId); + Task> GetSideNavStreamWithFilter(int filterId); + Task> GetSideNavStreamsByLibraryId(int libraryId); + Task> GetSideNavStreamWithExternalSource(int externalSourceId); } public class UserRepository : IUserRepository @@ -118,6 +126,11 @@ public class UserRepository : IUserRepository _context.Entry(stream).State = EntityState.Modified; } + public void Update(AppUserSideNavStream stream) + { + _context.Entry(stream).State = EntityState.Modified; + } + public void Add(AppUserBookmark bookmark) { _context.AppUserBookmark.Add(bookmark); @@ -134,11 +147,16 @@ public class UserRepository : IUserRepository _context.AppUserBookmark.Remove(bookmark); } - public void Delete(IList streams) + public void Delete(IEnumerable streams) { _context.AppUserDashboardStream.RemoveRange(streams); } + public void Delete(IEnumerable streams) + { + _context.AppUserSideNavStream.RemoveRange(streams); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -353,6 +371,89 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetSideNavStreams(int userId, bool visibleOnly = false) + { + var sideNavStreams = await _context.AppUserSideNavStream + .Where(d => d.AppUserId == userId) + .WhereIf(visibleOnly, d => d.Visible) + .OrderBy(d => d.Order) + .Include(d => d.SmartFilter) + .Select(d => new SideNavStreamDto() + { + Id = d.Id, + Name = d.Name, + IsProvided = d.IsProvided, + SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id, + SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter, + LibraryId = d.LibraryId ?? 0, + ExternalSourceId = d.ExternalSourceId ?? 0, + StreamType = d.StreamType, + Order = d.Order, + Visible = d.Visible + }) + .ToListAsync(); + + var libraryIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.Library) + .Select(d => d.LibraryId) + .ToList(); + + var libraryDtos = _context.Library + .Where(l => libraryIds.Contains(l.Id)) + .ProjectTo(_mapper.ConfigurationProvider) + .ToList(); + + foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) + { + dto.Library = libraryDtos.FirstOrDefault(l => l.Id == dto.LibraryId); + } + + var externalSourceIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.ExternalSource) + .Select(d => d.ExternalSourceId) + .ToList(); + + var externalSourceDtos = _context.AppUserExternalSource + .Where(l => externalSourceIds.Contains(l.Id)) + .ProjectTo(_mapper.ConfigurationProvider) + .ToList(); + + foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.ExternalSource)) + { + dto.ExternalSource = externalSourceDtos.FirstOrDefault(l => l.Id == dto.ExternalSourceId); + } + + return sideNavStreams; + } + + public async Task GetSideNavStream(int streamId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + + public async Task> GetSideNavStreamWithFilter(int filterId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId) + .ToListAsync(); + } + + public async Task> GetSideNavStreamsByLibraryId(int libraryId) + { + return await _context.AppUserSideNavStream + .Where(d => d.LibraryId == libraryId) + .ToListAsync(); + } + + public async Task> GetSideNavStreamWithExternalSource(int externalSourceId) + { + return await _context.AppUserSideNavStream + .Where(d => d.ExternalSourceId == externalSourceId) + .ToListAsync(); + } + + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 742e527ba..488d58ad1 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -44,7 +44,7 @@ public static class Seed { new() { - Name = "On Deck", + Name = "on-deck", StreamType = DashboardStreamType.OnDeck, Order = 0, IsProvided = true, @@ -52,7 +52,7 @@ public static class Seed }, new() { - Name = "Recently Updated", + Name = "recently-updated", StreamType = DashboardStreamType.RecentlyUpdated, Order = 1, IsProvided = true, @@ -60,7 +60,7 @@ public static class Seed }, new() { - Name = "Newly Added", + Name = "newly-added", StreamType = DashboardStreamType.NewlyAdded, Order = 2, IsProvided = true, @@ -68,7 +68,7 @@ public static class Seed }, new() { - Name = "More In", + Name = "more-in-genre", StreamType = DashboardStreamType.MoreInGenre, Order = 3, IsProvided = true, @@ -76,6 +76,50 @@ public static class Seed }, }.ToArray()); + public static readonly ImmutableArray DefaultSideNavStreams = ImmutableArray.Create(new[] + { + new AppUserSideNavStream() + { + Name = "want-to-read", + StreamType = SideNavStreamType.WantToRead, + Order = 1, + IsProvided = true, + Visible = true + }, + new AppUserSideNavStream() + { + Name = "collections", + StreamType = SideNavStreamType.Collections, + Order = 2, + IsProvided = true, + Visible = true + }, + new AppUserSideNavStream() + { + Name = "reading-lists", + StreamType = SideNavStreamType.ReadingLists, + Order = 3, + IsProvided = true, + Visible = true + }, + new AppUserSideNavStream() + { + Name = "bookmarks", + StreamType = SideNavStreamType.Bookmarks, + Order = 4, + IsProvided = true, + Visible = true + }, + new AppUserSideNavStream() + { + Name = "all-series", + StreamType = SideNavStreamType.AllSeries, + Order = 5, + IsProvided = true, + Visible = true + } + }); + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -137,6 +181,31 @@ public static class Seed } } + public static async Task SeedDefaultSideNavStreams(IUnitOfWork unitOfWork) + { + var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams); + foreach (var user in allUsers) + { + if (user.SideNavStreams.Count != 0) continue; + user.SideNavStreams ??= new List(); + foreach (var defaultStream in DefaultSideNavStreams) + { + var newStream = new AppUserSideNavStream() + { + Name = defaultStream.Name, + IsProvided = defaultStream.IsProvided, + Order = defaultStream.Order, + StreamType = defaultStream.StreamType, + Visible = defaultStream.Visible, + }; + + user.SideNavStreams.Add(newStream); + } + unitOfWork.UserRepository.Update(user); + await unitOfWork.CommitAsync(); + } + } + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index c001efa58..00406fa3d 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -29,6 +29,7 @@ public interface IUnitOfWork IScrobbleRepository ScrobbleRepository { get; } IUserTableOfContentRepository UserTableOfContentRepository { get; } IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } + IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -70,6 +71,7 @@ public class UnitOfWork : IUnitOfWork public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper); public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper); public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper); + public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index d8e2c06cc..62c8cc81a 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -76,6 +76,11 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard /// public IList DashboardStreams { get; set; } = null!; + /// + /// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's SideNav + /// + public IList SideNavStreams { get; set; } = null!; + public IList ExternalSources { get; set; } = null!; /// diff --git a/API/Entities/AppUserExternalSource.cs b/API/Entities/AppUserExternalSource.cs new file mode 100644 index 000000000..502204831 --- /dev/null +++ b/API/Entities/AppUserExternalSource.cs @@ -0,0 +1,12 @@ +namespace API.Entities; + +public class AppUserExternalSource +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string Host { get; set; } + public required string ApiKey { get; set; } + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } +} diff --git a/API/Entities/AppUserSideNavStream.cs b/API/Entities/AppUserSideNavStream.cs new file mode 100644 index 000000000..a164b0a1f --- /dev/null +++ b/API/Entities/AppUserSideNavStream.cs @@ -0,0 +1,34 @@ +namespace API.Entities; + +public class AppUserSideNavStream +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// Is System Provided + /// + public bool IsProvided { get; set; } + /// + /// Sort Order on the Dashboard + /// + public int Order { get; set; } + /// + /// Library Id is for StreamType.Library only + /// + public int? LibraryId { get; set; } + /// + /// Only set for StreamType.ExternalSource + /// + public int? ExternalSourceId { get; set; } + /// + /// For system provided + /// + public SideNavStreamType StreamType { get; set; } + public bool Visible { get; set; } + /// + /// If Not IsProvided, the appropriate smart filter + /// + public AppUserSmartFilter? SmartFilter { get; set; } + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } +} diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs new file mode 100644 index 000000000..3150bf08e --- /dev/null +++ b/API/Entities/SideNavStreamType.cs @@ -0,0 +1,13 @@ +namespace API.Entities; + +public enum SideNavStreamType +{ + Collections = 1, + ReadingLists = 2, + Bookmarks = 3, + Library = 4, + SmartFilter = 5, + ExternalSource = 6, + AllSeries = 7, + WantToRead = 8, +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 6bddccd01..c0904e551 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -51,6 +51,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 828041f1c..006364ffb 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -141,6 +141,17 @@ public static class IncludesExtensions .ThenInclude(s => s.SmartFilter); } + if (includeFlags.HasFlag(AppUserIncludes.SideNavStreams)) + { + query = query.Include(u => u.SideNavStreams) + .ThenInclude(s => s.SmartFilter); + } + + if (includeFlags.HasFlag(AppUserIncludes.ExternalSources)) + { + query = query.Include(u => u.ExternalSources); + } + return query.AsSplitQuery(); } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index e3f8b8202..9585b92a9 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -16,6 +16,7 @@ using API.DTOs.Scrobbling; using API.DTOs.Search; using API.DTOs.SeriesDetail; using API.DTOs.Settings; +using API.DTOs.SideNav; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; @@ -54,6 +55,7 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap(); + CreateMap(); CreateMap() .ForMember(dest => dest.LibraryId, opt => diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index fd39f4b11..8a3ce9096 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -29,20 +29,39 @@ public class AppUserBuilder : IEntityBuilder Progresses = new List(), Devices = new List(), Id = 0, - DashboardStreams = new List() + DashboardStreams = new List(), + SideNavStreams = new List() }; foreach (var s in Seed.DefaultStreams) { _appUser.DashboardStreams.Add(s); } + foreach (var s in Seed.DefaultSideNavStreams) + { + _appUser.SideNavStreams.Add(s); + } } - public AppUserBuilder WithLibrary(Library library) + public AppUserBuilder WithLibrary(Library library, bool createSideNavStream = false) { _appUser.Libraries.Add(library); + if (!createSideNavStream) return this; + + if (library.Id != 0 && _appUser.SideNavStreams.Any(s => s.LibraryId == library.Id)) return this; + _appUser.SideNavStreams.Add(new AppUserSideNavStream() + { + Name = library.Name, + IsProvided = false, + Visible = true, + LibraryId = library.Id, + StreamType = SideNavStreamType.Library, + Order = _appUser.SideNavStreams.Max(s => s.Order) + 1, + }); + return this; } + public AppUserBuilder WithLocale(string locale) { _appUser.UserPreferences.Locale = locale; diff --git a/API/I18N/en.json b/API/I18N/en.json index 6f218e3e4..4087b2d60 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -158,6 +158,13 @@ "search-description": "Search for Series, Collections, or Reading Lists", "favicon-doesnt-exist": "Favicon does not exist", "smart-filter-doesnt-exist": "Smart Filter doesn't exist", + "smart-filter-already-in-use": "There is an existing stream with this Smart Filter", + "dashboard-stream-doesnt-exist": "Dashboard Stream doesn't exist", + "sidenav-stream-doesnt-exist": "SideNav Stream doesn't exist", + "external-source-already-exists": "External Source already exists", + "external-source-required": "ApiKey and Host required", + "external-source-doesnt-exist": "External Source doesn't exist", + "external-source-already-in-use": "There is an existing stream with this External Source", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", diff --git a/API/Program.cs b/API/Program.cs index 02c725247..09c1707af 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -64,6 +64,7 @@ public class Program using var scope = host.Services.CreateScope(); var services = scope.ServiceProvider; + var unitOfWork = services.GetRequiredService(); try { @@ -87,10 +88,12 @@ public class Program await context.Database.MigrateAsync(); + await Seed.SeedRoles(services.GetRequiredService>()); await Seed.SeedSettings(context, directoryService); await Seed.SeedThemes(context); - await Seed.SeedDefaultStreams(services.GetRequiredService()); + await Seed.SeedDefaultStreams(unitOfWork); + await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); } catch (Exception ex) @@ -106,7 +109,6 @@ public class Program } // Update the logger with the log level - var unitOfWork = services.GetRequiredService(); var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); LogLevelOptions.SwitchLogLevel(settings.LoggingLevel); diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs new file mode 100644 index 000000000..07f1af73b --- /dev/null +++ b/API/Services/StreamService.cs @@ -0,0 +1,354 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Dashboard; +using API.DTOs.SideNav; +using API.Entities; +using API.Entities.Enums; +using API.SignalR; +using Kavita.Common; +using Kavita.Common.Helpers; + +namespace API.Services; + +/// +/// For SideNavStream and DashboardStream manipulation +/// +public interface IStreamService +{ + Task> GetDashboardStreams(int userId, bool visibleOnly = true); + Task> GetSidenavStreams(int userId, bool visibleOnly = true); + Task> GetExternalSources(int userId); + Task CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId); + Task UpdateDashboardStream(int userId, DashboardStreamDto dto); + Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto); + Task CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId); + Task CreateSideNavStreamFromExternalSource(int userId, int externalSourceId); + Task UpdateSideNavStream(int userId, SideNavStreamDto dto); + Task UpdateSideNavStreamPosition(int userId, UpdateStreamPositionDto dto); + Task CreateExternalSource(int userId, ExternalSourceDto dto); + Task UpdateExternalSource(int userId, ExternalSourceDto dto); + Task DeleteExternalSource(int userId, int externalSourceId); +} + +public class StreamService : IStreamService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private readonly ILocalizationService _localizationService; + + public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService) + { + _unitOfWork = unitOfWork; + _eventHub = eventHub; + _localizationService = localizationService; + } + + public async Task> GetDashboardStreams(int userId, bool visibleOnly = true) + { + return await _unitOfWork.UserRepository.GetDashboardStreams(userId, visibleOnly); + } + + public async Task> GetSidenavStreams(int userId, bool visibleOnly = true) + { + return await _unitOfWork.UserRepository.GetSideNavStreams(userId, visibleOnly); + } + + public async Task> GetExternalSources(int userId) + { + return await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId); + } + + public async Task CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.DashboardStreams); + if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user")); + + var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId); + if (smartFilter == null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-doesnt-exist")); + + var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId); + if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-already-in-use")); + + var maxOrder = user!.DashboardStreams.Max(d => d.Order); + var createdStream = new AppUserDashboardStream() + { + Name = smartFilter.Name, + IsProvided = false, + StreamType = DashboardStreamType.SmartFilter, + Visible = true, + Order = maxOrder + 1, + SmartFilter = smartFilter + }; + + user.DashboardStreams.Add(createdStream); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + var ret = new DashboardStreamDto() + { + Name = createdStream.Name, + IsProvided = createdStream.IsProvided, + Visible = createdStream.Visible, + Order = createdStream.Order, + SmartFilterEncoded = smartFilter.Filter, + StreamType = createdStream.StreamType + }; + + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), + userId); + + return ret; + } + + public async Task UpdateDashboardStream(int userId, DashboardStreamDto dto) + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id); + if (stream == null) throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist")); + stream.Visible = dto.Visible; + + _unitOfWork.UserRepository.Update(stream); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId), + userId); + } + + public async Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, + AppUserIncludes.DashboardStreams); + var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.Id); + if (stream == null) + throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist")); + if (stream.Order == dto.ToPosition) return ; + + var list = user!.DashboardStreams.ToList(); + ReorderItems(list, stream.Id, dto.ToPosition); + user.DashboardStreams = list; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), + user.Id); + } + + public async Task CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams); + if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user")); + + var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId); + if (smartFilter == null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-doesnt-exist")); + + var stream = user?.SideNavStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId); + if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "smart-filter-already-in-use")); + + var maxOrder = user!.SideNavStreams.Max(d => d.Order); + var createdStream = new AppUserSideNavStream() + { + Name = smartFilter.Name, + IsProvided = false, + StreamType = SideNavStreamType.SmartFilter, + Visible = true, + Order = maxOrder + 1, + SmartFilter = smartFilter + }; + + user.SideNavStreams.Add(createdStream); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + var ret = new SideNavStreamDto() + { + Name = createdStream.Name, + IsProvided = createdStream.IsProvided, + Visible = createdStream.Visible, + Order = createdStream.Order, + SmartFilterEncoded = smartFilter.Filter, + StreamType = createdStream.StreamType + }; + + + await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId), + userId); + return ret; + } + + public async Task CreateSideNavStreamFromExternalSource(int userId, int externalSourceId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams); + if (user == null) throw new KavitaException(await _localizationService.Translate(userId, "no-user")); + + var externalSource = await _unitOfWork.AppUserExternalSourceRepository.GetById(externalSourceId); + if (externalSource == null) throw new KavitaException(await _localizationService.Translate(userId, "external-source-doesnt-exist")); + + var stream = user?.SideNavStreams.FirstOrDefault(d => d.ExternalSourceId == externalSourceId); + if (stream != null) throw new KavitaException(await _localizationService.Translate(userId, "external-source-already-in-use")); + + var maxOrder = user!.SideNavStreams.Max(d => d.Order); + var createdStream = new AppUserSideNavStream() + { + Name = externalSource.Name, + IsProvided = false, + StreamType = SideNavStreamType.ExternalSource, + Visible = true, + Order = maxOrder + 1, + ExternalSourceId = externalSource.Id + }; + + user.SideNavStreams.Add(createdStream); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + var ret = new SideNavStreamDto() + { + Name = createdStream.Name, + IsProvided = createdStream.IsProvided, + Visible = createdStream.Visible, + Order = createdStream.Order, + StreamType = createdStream.StreamType, + ExternalSource = new ExternalSourceDto() + { + Host = externalSource.Host, + Id = externalSource.Id, + Name = externalSource.Name, + ApiKey = externalSource.ApiKey + } + }; + + + await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId), + userId); + return ret; + } + + public async Task UpdateSideNavStream(int userId, SideNavStreamDto dto) + { + var stream = await _unitOfWork.UserRepository.GetSideNavStream(dto.Id); + if (stream == null) + throw new KavitaException(await _localizationService.Translate(userId, "sidenav-stream-doesnt-exist")); + stream.Visible = dto.Visible; + + _unitOfWork.UserRepository.Update(stream); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId), + userId); + } + + public async Task UpdateSideNavStreamPosition(int userId, UpdateStreamPositionDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, + AppUserIncludes.SideNavStreams); + var stream = user?.SideNavStreams.FirstOrDefault(d => d.Id == dto.Id); + if (stream == null) throw new KavitaException(await _localizationService.Translate(userId, "sidenav-stream-doesnt-exist")); + if (stream.Order == dto.ToPosition) return; + + var list = user!.SideNavStreams.ToList(); + ReorderItems(list, stream.Id, dto.ToPosition); + user.SideNavStreams = list; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId), + userId); + } + + public async Task CreateExternalSource(int userId, ExternalSourceDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, + AppUserIncludes.ExternalSources); + if (user == null) throw new KavitaException("not-authenticated"); + + if (user.ExternalSources.Any(s => s.Host == dto.Host)) + { + throw new KavitaException("external-source-already-exists"); + } + + if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.Name)) throw new KavitaException("external-source-required"); + if (!UrlHelper.StartsWithHttpOrHttps(dto.Host)) throw new KavitaException("external-source-host-format"); + + + var newSource = new AppUserExternalSource() + { + Name = dto.Name, + Host = UrlHelper.EnsureEndsWithSlash( + UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)), + ApiKey = dto.ApiKey + }; + user.ExternalSources.Add(newSource); + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + dto.Id = newSource.Id; + + return dto; + } + + public async Task UpdateExternalSource(int userId, ExternalSourceDto dto) + { + var source = await _unitOfWork.AppUserExternalSourceRepository.GetById(dto.Id); + if (source == null) throw new KavitaException("external-source-doesnt-exist"); + if (source.AppUserId != userId) throw new KavitaException("external-source-doesnt-exist"); + + if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.Host) || string.IsNullOrEmpty(dto.Name)) throw new KavitaException("external-source-required"); + + source.Host = UrlHelper.EnsureEndsWithSlash( + UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)); + source.ApiKey = dto.ApiKey; + source.Name = dto.Name; + + _unitOfWork.AppUserExternalSourceRepository.Update(source); + await _unitOfWork.CommitAsync(); + + dto.Host = source.Host; + return dto; + } + + public async Task DeleteExternalSource(int userId, int externalSourceId) + { + var source = await _unitOfWork.AppUserExternalSourceRepository.GetById(externalSourceId); + if (source == null) throw new KavitaException("external-source-doesnt-exist"); + if (source.AppUserId != userId) throw new KavitaException("external-source-doesnt-exist"); + + _unitOfWork.AppUserExternalSourceRepository.Delete(source); + + // Find all SideNav's with this source and delete them as well + var streams2 = await _unitOfWork.UserRepository.GetSideNavStreamWithExternalSource(externalSourceId); + _unitOfWork.UserRepository.Delete(streams2); + + await _unitOfWork.CommitAsync(); + } + + private static void ReorderItems(List items, int itemId, int toPosition) + { + var item = items.Find(r => r.Id == itemId); + if (item != null) + { + items.Remove(item); + items.Insert(toPosition, item); + } + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + } + + private static void ReorderItems(List items, int itemId, int toPosition) + { + var item = items.Find(r => r.Id == itemId); + if (item != null) + { + items.Remove(item); + items.Insert(toPosition, item); + } + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + } +} diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index ea3c64699..9bb7b86d5 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using API.DTOs.Update; using API.SignalR; using Flurl.Http; +using HtmlAgilityPack; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using MarkdownDeep; @@ -103,6 +104,7 @@ public class VersionUpdaterService : IVersionUpdaterService }; } + public async Task PushUpdate(UpdateNotificationDto? update) { if (update == null) return; diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 23afa3d60..76fcae5fc 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -126,6 +126,10 @@ public static class MessageFactory /// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout /// public const string DashboardUpdate = "DashboardUpdate"; + /// + /// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout + /// + public const string SideNavUpdate = "SideNavUpdate"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -142,6 +146,21 @@ public static class MessageFactory }; } + public static SignalRMessage SideNavUpdateEvent(int userId) + { + return new SignalRMessage() + { + Name = SideNavUpdate, + Title = "SideNav Update", + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new + { + UserId = userId + } + }; + } + public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) { diff --git a/API/Startup.cs b/API/Startup.cs index 5f4ec69d7..e8d8d6c2e 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -250,6 +250,9 @@ public class Startup // v0.7.6 await MigrateExistingRatings.Migrate(dataContext, logger); + // v0.7.9 + await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 4d04eb94d..e2af4c32d 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text.Json; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using Microsoft.Extensions.Hosting; namespace Kavita.Common; @@ -214,13 +215,8 @@ public static class Configuration var baseUrl = jsonObj.BaseUrl; if (!string.IsNullOrEmpty(baseUrl)) { - baseUrl = !baseUrl.StartsWith('/') - ? $"/{baseUrl}" - : baseUrl; - - baseUrl = !baseUrl.EndsWith('/') - ? $"{baseUrl}/" - : baseUrl; + baseUrl = UrlHelper.EnsureStartsWithSlash(baseUrl); + baseUrl = UrlHelper.EnsureEndsWithSlash(baseUrl); return baseUrl; } diff --git a/Kavita.Common/Helpers/UrlHelper.cs b/Kavita.Common/Helpers/UrlHelper.cs new file mode 100644 index 000000000..320c0346c --- /dev/null +++ b/Kavita.Common/Helpers/UrlHelper.cs @@ -0,0 +1,41 @@ +namespace Kavita.Common.Helpers; + +#nullable enable +public static class UrlHelper +{ + public static bool StartsWithHttpOrHttps(string? url) + { + if (string.IsNullOrEmpty(url)) return false; + return url.StartsWith("http://") || url.StartsWith("https://"); + } + + public static string? EnsureStartsWithHttpOrHttps(string? url) + { + if (string.IsNullOrEmpty(url)) return url; + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + { + // URL doesn't start with "http://" or "https://", so add "http://" + return "http://" + url; + } + + return url; + } + + public static string? EnsureEndsWithSlash(string? url) + { + if (string.IsNullOrEmpty(url)) return url; + + return !url.EndsWith('/') + ? $"{url}/" + : url; + + } + + public static string? EnsureStartsWithSlash(string? url) + { + if (string.IsNullOrEmpty(url)) return url; + return !url.StartsWith('/') + ? $"/{url}" + : url; + } +} diff --git a/README.md b/README.md index 79d7779b9..af047f83f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ your reading collection with your friends and family! - Ability to manage users with rich Role-based management for age restrictions, abilities within the app, etc - Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc - Full Localization Support +- Ability to customize your dashboard and side nav with smart filters ## Support [![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/KavitaManga/) [![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem) -[![GitHub - Bugs and Feature Requests Only](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Kareadita/Kavita/issues) +[![GitHub - Bugs Only](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Kareadita/Kavita/issues) ## Demo If you want to try out Kavita, we have a demo up: @@ -102,9 +103,6 @@ Thank you to [ JetBrains](http: * [ Rider](http://www.jetbrains.com/rider/) * [ dotTrace](http://www.jetbrains.com/dottrace/) -## Palace-Designs -We would like to extend a big thank you to [](https://www.palace-designs.com/) who hosts our infrastructure pro-bono. - ## Localization Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro-bono. If you want to see Kavita in your language, please help us localize. diff --git a/UI/Web/src/app/_models/common-stream.ts b/UI/Web/src/app/_models/common-stream.ts new file mode 100644 index 000000000..0ef893408 --- /dev/null +++ b/UI/Web/src/app/_models/common-stream.ts @@ -0,0 +1,8 @@ +export interface CommonStream { + id: number; + name: string; + isProvided: boolean; + order: number; + visible: boolean; + smartFilterEncoded?: string; +} diff --git a/UI/Web/src/app/_models/dashboard/dashboard-stream.ts b/UI/Web/src/app/_models/dashboard/dashboard-stream.ts index 69824492d..b24be8652 100644 --- a/UI/Web/src/app/_models/dashboard/dashboard-stream.ts +++ b/UI/Web/src/app/_models/dashboard/dashboard-stream.ts @@ -1,7 +1,8 @@ import {Observable} from "rxjs"; import {StreamType} from "./stream-type.enum"; +import {CommonStream} from "../common-stream"; -export interface DashboardStream { +export interface DashboardStream extends CommonStream { id: number; name: string; isProvided: boolean; @@ -12,3 +13,5 @@ export interface DashboardStream { order: number; visible: boolean; } + + diff --git a/UI/Web/src/app/_models/events/sidenav-update-event.ts b/UI/Web/src/app/_models/events/sidenav-update-event.ts new file mode 100644 index 000000000..eebd73108 --- /dev/null +++ b/UI/Web/src/app/_models/events/sidenav-update-event.ts @@ -0,0 +1,3 @@ +export interface SideNavUpdateEvent { + userId: number; +} diff --git a/UI/Web/src/app/_models/sidenav/external-source.ts b/UI/Web/src/app/_models/sidenav/external-source.ts new file mode 100644 index 000000000..20b165c99 --- /dev/null +++ b/UI/Web/src/app/_models/sidenav/external-source.ts @@ -0,0 +1,6 @@ +export interface ExternalSource { + id: number; + name: string; + host: string; + apiKey: string; +} diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts new file mode 100644 index 000000000..a294f9696 --- /dev/null +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts @@ -0,0 +1,10 @@ +export enum SideNavStreamType { + Collections = 1, + ReadingLists = 2, + Bookmarks = 3, + Library = 4, + SmartFilter = 5, + ExternalSource = 6, + AllSeries = 7, + WantToRead = 8, +} diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts new file mode 100644 index 000000000..7dd672f50 --- /dev/null +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts @@ -0,0 +1,18 @@ +import {SideNavStreamType} from "./sidenav-stream-type.enum"; +import {Library, LibraryType} from "../library"; +import {CommonStream} from "../common-stream"; +import {ExternalSource} from "./external-source"; + +export interface SideNavStream extends CommonStream { + name: string; + order: number; + libraryId?: number; + isProvided: boolean; + streamType: SideNavStreamType; + library?: Library; + visible: boolean; + smartFilterId: number; + smartFilterEncoded?: string; + externalSource?: ExternalSource; + +} diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts index cc32046a6..9d9deeffb 100644 --- a/UI/Web/src/app/_services/dashboard.service.ts +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -12,18 +12,18 @@ export class DashboardService { constructor(private httpClient: HttpClient) { } getDashboardStreams(visibleOnly = true) { - return this.httpClient.get>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly); + return this.httpClient.get>(this.baseUrl + 'stream/dashboard?visibleOnly=' + visibleOnly); } updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) { - return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse); + return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-position', {streamName, id: dashboardStreamId, fromPosition, toPosition}, TextResonse); } updateDashboardStream(stream: DashboardStream) { - return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse); + return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-stream', stream, TextResonse); } createDashboardStream(smartFilterId: number) { - return this.httpClient.post(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); + return this.httpClient.post(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index a2a0e87ba..4eb17fb97 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -8,6 +8,7 @@ import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; import { UserUpdateEvent } from '../_models/events/user-update-event'; import { User } from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; +import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -86,7 +87,11 @@ export enum EVENTS { /** * User's dashboard needs to be re-rendered */ - DashboardUpdate = 'DashboardUpdate' + DashboardUpdate = 'DashboardUpdate', + /** + * User's sidenav needs to be re-rendered + */ + SideNavUpdate = 'SideNavUpdate' } export interface Message { @@ -187,6 +192,12 @@ export class MessageHubService { payload: resp.body as DashboardUpdateEvent }); }); + this.hubConnection.on(EVENTS.SideNavUpdate, resp => { + this.messagesSource.next({ + event: EVENTS.SideNavUpdate, + payload: resp.body as SideNavUpdateEvent + }); + }); this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index db1c6b048..a91e9d82e 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,6 +1,11 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; import { ReplaySubject, take } from 'rxjs'; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {SideNavStream} from "../_models/sidenav/sidenav-stream"; +import {TextResonse} from "../_types/text-response"; +import {DashboardStream} from "../_models/dashboard/dashboard-stream"; @Injectable({ providedIn: 'root' @@ -27,15 +32,36 @@ export class NavService { sideNavVisibility$ = this.sideNavVisibilitySource.asObservable(); private renderer: Renderer2; + baseUrl = environment.apiUrl; - constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2) { + constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) { this.renderer = rendererFactory.createRenderer(null, null); this.showNavBar(); const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false; this.sideNavCollapseSource.next(sideNavState); this.showSideNav(); } - + + getSideNavStreams(visibleOnly = true) { + return this.httpClient.get>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly); + } + + updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) { + return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse); + } + + updateSideNavStream(stream: SideNavStream) { + return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-stream', stream, TextResonse); + } + + createSideNavStream(smartFilterId: number) { + return this.httpClient.post(this.baseUrl + 'stream/add-sidenav-stream?smartFilterId=' + smartFilterId, {}); + } + + createSideNavStreamFromExternalSource(externalSourceId: number) { + return this.httpClient.post(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {}); + } + /** * Shows the top nav bar. This should be visible on all pages except the reader. */ @@ -47,7 +73,7 @@ export class NavService { } /** - * Hides the top nav bar. + * Hides the top nav bar. */ hideNavBar() { this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px'); diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss index 38f0fd181..2046a4625 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss @@ -9,13 +9,17 @@ ::ng-deep .changelog { h1 { - font-size: 26px; + font-size: 26px; } p, ul { margin-bottom: 0px; } - - + + img { + max-width: 100% !important; + } + + } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index a3239db85..86b6a60e6 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -89,7 +89,9 @@
- +

This must be a valid year greater than 1000 and 4 characters long diff --git a/UI/Web/src/app/external-source.service.ts b/UI/Web/src/app/external-source.service.ts new file mode 100644 index 000000000..cc561492c --- /dev/null +++ b/UI/Web/src/app/external-source.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {ExternalSource} from "./_models/sidenav/external-source"; +import {TextResonse} from "./_types/text-response"; +import {map} from "rxjs/operators"; + +@Injectable({ + providedIn: 'root' +}) +export class ExternalSourceService { + + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + getExternalSources() { + return this.httpClient.get>(this.baseUrl + 'stream/external-sources'); + } + + createSource(source: ExternalSource) { + return this.httpClient.post(this.baseUrl + 'stream/create-external-source', source); + } + + updateSource(source: ExternalSource) { + return this.httpClient.post(this.baseUrl + 'stream/update-external-source', source); + } + + deleteSource(externalSourceId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/delete-external-source?externalSourceId=' + externalSourceId); + } + + sourceExists(name: string, host: string, apiKey: string) { + return this.httpClient.get(this.baseUrl + `stream/external-source-exists?host=${encodeURIComponent(host)}&name=${name}&apiKey=${apiKey}`, TextResonse) + .pipe(map(s => s == 'true')); + } +} diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 3a9d98ae6..c7d0c3513 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -443,7 +443,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { get LayoutMode() { return LayoutMode; } get ReadingDirection() { return ReadingDirection; } get Breakpoint() { return Breakpoint; } - get FittingOption() { return this.generalSettingsForm.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; } + get FittingOption() { return this.generalSettingsForm?.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; } get ReadingAreaWidth() { return this.readingArea?.nativeElement.scrollWidth - this.readingArea?.nativeElement.clientWidth; } diff --git a/UI/Web/src/app/pipe/stream-name.pipe.ts b/UI/Web/src/app/pipe/stream-name.pipe.ts new file mode 100644 index 000000000..632beb0e7 --- /dev/null +++ b/UI/Web/src/app/pipe/stream-name.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'streamName', + standalone: true, + pure: true +}) +export class StreamNamePipe implements PipeTransform { + + transform(value: string): unknown { + return translate('stream-pipe.' + value); + } + +} diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html index 766dc309c..838779304 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html @@ -15,7 +15,7 @@ - @@ -34,12 +34,12 @@

- + - diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts index 1a32ffa3a..d341c349b 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts @@ -8,6 +8,7 @@ export interface IndexUpdateEvent { fromPosition: number; toPosition: number; item: any; + fromAccessibilityMode: boolean; } export interface ItemRemoveEvent { @@ -35,6 +36,10 @@ export class DraggableOrderedListComponent { * Parent scroll for virtualize pagination */ @Input() parentScroll!: Element | Window; + /** + * Disables drag and drop functionality. Useful if a filter is present which will skew actual index. + */ + @Input() disabled: boolean = false; @Input() trackByIdentity: TrackByFunction = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`; @Output() orderUpdated: EventEmitter = new EventEmitter(); @Output() itemRemove: EventEmitter = new EventEmitter(); @@ -52,21 +57,23 @@ export class DraggableOrderedListComponent { this.orderUpdated.emit({ fromPosition: event.previousIndex, toPosition: event.currentIndex, - item: this.items[event.currentIndex] + item: this.items[event.currentIndex], + fromAccessibilityMode: false }); this.cdRef.markForCheck(); } updateIndex(previousIndex: number, item: any) { // get the new value of the input - var inputElem = document.querySelector('#reorder-' + previousIndex); + const inputElem = document.querySelector('#reorder-' + previousIndex); const newIndex = parseInt(inputElem.value, 10); if (previousIndex === newIndex) return; moveItemInArray(this.items, previousIndex, newIndex); this.orderUpdated.emit({ fromPosition: previousIndex, toPosition: newIndex, - item: this.items[newIndex] + item: this.items[newIndex], + fromAccessibilityMode: true }); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss index a29ca3998..b61e3b7b5 100644 --- a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss @@ -2,8 +2,13 @@ width: 100%; word-wrap: break-word; white-space: pre-wrap; + } -img { - max-width: 100%; +::ng-deep .update-body { + img { + max-width: 100%; + } } + + diff --git a/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html index 773fc1c19..9b09cc001 100644 --- a/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html +++ b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html @@ -1,29 +1,37 @@ - + + + + diff --git a/UI/Web/src/app/sidenav/_components/edit-external-source-item/edit-external-source-item.component.scss b/UI/Web/src/app/sidenav/_components/edit-external-source-item/edit-external-source-item.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/sidenav/_components/edit-external-source-item/edit-external-source-item.component.ts b/UI/Web/src/app/sidenav/_components/edit-external-source-item/edit-external-source-item.component.ts new file mode 100644 index 000000000..20b01e131 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/edit-external-source-item/edit-external-source-item.component.ts @@ -0,0 +1,107 @@ +import {ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import {ExternalSource} from "../../../_models/sidenav/external-source"; +import {NgbCollapse} from "@ng-bootstrap/ng-bootstrap"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {ExternalSourceService} from "../../../external-source.service"; +import {distinctUntilChanged, filter, tap} from "rxjs/operators"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {switchMap} from "rxjs"; +import {ToastrModule, ToastrService} from "ngx-toastr"; + +@Component({ + selector: 'app-edit-external-source-item', + standalone: true, + imports: [CommonModule, NgbCollapse, ReactiveFormsModule, TranslocoDirective], + templateUrl: './edit-external-source-item.component.html', + styleUrls: ['./edit-external-source-item.component.scss'] +}) +export class EditExternalSourceItemComponent implements OnInit { + + @Input({required: true}) source!: ExternalSource; + @Output() sourceUpdate = new EventEmitter(); + @Output() sourceDelete = new EventEmitter(); + @Input() isViewMode: boolean = true; + + formGroup: FormGroup = new FormGroup({}); + private readonly destroyRef = inject(DestroyRef); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly externalSourceService = inject(ExternalSourceService); + private readonly toastr = inject(ToastrService); + + hasErrors(controlName: string) { + const errors = this.formGroup.get(controlName)?.errors; + return Object.values(errors || []).filter(v => v).length > 0; + } + + constructor() {} + + ngOnInit(): void { + this.formGroup.addControl('name', new FormControl(this.source.name, [Validators.required])); + this.formGroup.addControl('host', new FormControl(this.source.host, [Validators.required, Validators.pattern(/^(http:|https:)+[^\s]+[\w]\/?$/)])); + this.formGroup.addControl('apiKey', new FormControl(this.source.apiKey, [Validators.required])); + this.cdRef.markForCheck(); + } + + resetForm() { + this.formGroup.get('host')?.setValue(this.source.host); + this.formGroup.get('name')?.setValue(this.source.name); + this.formGroup.get('apiKey')?.setValue(this.source.apiKey); + this.cdRef.markForCheck(); + } + + saveForm() { + if (this.source === undefined) return; + + const model = this.formGroup.value; + this.externalSourceService.sourceExists(model.host, model.name, model.apiKey).subscribe(exists => { + if (exists) { + this.toastr.error(translate('toasts.external-source-already-exists')); + return; + } + + if (this.source.id === 0) { + // We need to create a new one + this.externalSourceService.createSource({id: 0, ...this.formGroup.value}).subscribe((updatedSource) => { + this.source = {...updatedSource}; + this.sourceUpdate.emit(this.source); + this.toggleViewMode(); + }); + return; + } + + this.externalSourceService.updateSource({id: this.source.id, ...this.formGroup.value}).subscribe((updatedSource) => { + this.source!.host = this.formGroup.value.host; + this.source!.apiKey = this.formGroup.value.apiKey; + this.source!.name = this.formGroup.value.name; + + this.sourceUpdate.emit(this.source); + this.toggleViewMode(); + }); + }); + } + delete() { + if (this.source.id === 0) { + this.sourceDelete.emit(this.source); + if (!this.isViewMode) { + this.toggleViewMode(); + } + return; + } + + this.externalSourceService.deleteSource(this.source.id).subscribe(() => { + this.sourceDelete.emit(this.source); + if (!this.isViewMode) { + this.toggleViewMode(); + } + }); + } + + toggleViewMode() { + this.isViewMode = !this.isViewMode; + if (!this.isViewMode) { + this.resetForm(); + } + } +} diff --git a/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html new file mode 100644 index 000000000..0be74fd3d --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html @@ -0,0 +1,28 @@ + +

+ {{t('description')}} + {{t('help-link')}} +

+ + +
+
+ +
+ + +
+
+
+ +
+
+ + + + +
+ diff --git a/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.scss b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.ts b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.ts new file mode 100644 index 000000000..ebad60608 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.ts @@ -0,0 +1,66 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core'; +import {CommonModule, NgOptimizedImage} from '@angular/common'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {NgbCollapse, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {AccountService} from "../../../_services/account.service"; +import {ToastrService} from "ngx-toastr"; +import {EditExternalSourceItemComponent} from "../edit-external-source-item/edit-external-source-item.component"; +import {ExternalSource} from "../../../_models/sidenav/external-source"; +import {ExternalSourceService} from "../../../external-source.service"; +import {FilterPipe} from "../../../pipe/filter.pipe"; +import {SmartFilter} from "../../../_models/metadata/v2/smart-filter"; + +@Component({ + selector: 'app-manage-external-sources', + standalone: true, + imports: [CommonModule, FormsModule, NgOptimizedImage, NgbTooltip, ReactiveFormsModule, TranslocoDirective, NgbCollapse, EditExternalSourceItemComponent, FilterPipe], + templateUrl: './manage-external-sources.component.html', + styleUrls: ['./manage-external-sources.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageExternalSourcesComponent { + + externalSources: Array = []; + private readonly cdRef = inject(ChangeDetectorRef); + private readonly externalSourceService = inject(ExternalSourceService); + + listForm: FormGroup = new FormGroup({ + 'filterQuery': new FormControl('', []) + }); + + filterList = (listItem: ExternalSource) => { + const filterVal = (this.listForm.value.filterQuery || '').toLowerCase(); + return listItem.name.toLowerCase().indexOf(filterVal) >= 0 || listItem.host.toLowerCase().indexOf(filterVal) >= 0; + } + + constructor(public accountService: AccountService) { + this.externalSourceService.getExternalSources().subscribe(data => { + this.externalSources = data; + this.cdRef.markForCheck(); + }); + } + + resetFilter() { + this.listForm.get('filterQuery')?.setValue(''); + this.cdRef.markForCheck(); + } + + addNewExternalSource() { + this.externalSources.unshift({id: 0, name: '', host: '', apiKey: ''}); + this.cdRef.markForCheck(); + } + + updateSource(index: number, updatedSource: ExternalSource) { + this.externalSources[index] = updatedSource; + this.cdRef.markForCheck(); + } + + deleteSource(index: number, updatedSource: ExternalSource) { + this.externalSources.splice(index, 1); + this.resetFilter(); + this.cdRef.markForCheck(); + } + + +} diff --git a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html new file mode 100644 index 000000000..04194f47a --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html @@ -0,0 +1,25 @@ + +
+
+ +
+ + +
+
+
+ +
    +
  • + {{f.name}} + +
  • + +
  • + {{t('no-data')}} +
  • +
+
diff --git a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.scss b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss similarity index 100% rename from UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.scss rename to UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss diff --git a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts similarity index 61% rename from UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts rename to UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts index c8d82280a..1778331cb 100644 --- a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts @@ -1,16 +1,18 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {FilterService} from "../../_services/filter.service"; -import {SmartFilter} from "../../_models/metadata/v2/smart-filter"; +import {FilterService} from "../../../_services/filter.service"; +import {SmartFilter} from "../../../_models/metadata/v2/smart-filter"; import {Router} from "@angular/router"; -import {ConfirmService} from "../../shared/confirm.service"; -import {translate} from "@ngneat/transloco"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; import {ToastrService} from "ngx-toastr"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {FilterPipe} from "../../../pipe/filter.pipe"; @Component({ selector: 'app-manage-smart-filters', standalone: true, - imports: [CommonModule], + imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, FilterPipe], templateUrl: './manage-smart-filters.component.html', styleUrls: ['./manage-smart-filters.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -23,6 +25,19 @@ export class ManageSmartFiltersComponent { private readonly cdRef = inject(ChangeDetectorRef); private readonly toastr = inject(ToastrService); filters: Array = []; + listForm: FormGroup = new FormGroup({ + 'filterQuery': new FormControl('', []) + }); + + filterList = (listItem: SmartFilter) => { + const filterVal = (this.listForm.value.filterQuery || '').toLowerCase(); + return listItem.name.toLowerCase().indexOf(filterVal) >= 0; + } + resetFilter() { + this.listForm.get('filterQuery')?.setValue(''); + this.cdRef.markForCheck(); + } + constructor() { this.loadData(); @@ -44,6 +59,7 @@ export class ManageSmartFiltersComponent { this.filterService.deleteFilter(f.id).subscribe(() => { this.toastr.success(translate('toasts.smart-filter-deleted')); + this.resetFilter(); this.loadData(); }); } diff --git a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.html b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.html index 910cd4625..e6d8bd043 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.html @@ -11,9 +11,16 @@ - + + + + + + + + diff --git a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.ts b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.ts index a9e0739fc..39808bdd0 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.ts @@ -41,6 +41,11 @@ export class SideNavItemComponent implements OnInit { * If external, link will be used as full href and rel will be applied */ @Input() external: boolean = false; + /** + * If using a link, then you can pass optional queryParameters + */ + @Input() queryParams: any | undefined = undefined; + @Input() comparisonMethod: 'startsWith' | 'equals' = 'equals'; private readonly destroyRef = inject(DestroyRef); @@ -54,8 +59,9 @@ export class SideNavItemComponent implements OnInit { takeUntilDestroyed(this.destroyRef), map(evt => evt as NavigationEnd)) .subscribe((evt: NavigationEnd) => { - this.updateHighlight(evt.url.split('?')[0]); - + const tokens = evt.url.split('?'); + const [token1, token2 = undefined] = tokens; + this.updateHighlight(token1, token2); }); } @@ -66,23 +72,31 @@ export class SideNavItemComponent implements OnInit { } - updateHighlight(page: string) { + updateHighlight(page: string, queryParams?: string) { if (this.link === undefined) { this.highlighted = false; this.cdRef.markForCheck(); return; } - if (!page.endsWith('/')) { + if (!page.endsWith('/') && !queryParams) { page = page + '/'; } + if (this.comparisonMethod === 'equals' && page === this.link) { this.highlighted = true; this.cdRef.markForCheck(); return; } if (this.comparisonMethod === 'startsWith' && page.startsWith(this.link)) { + + if (queryParams && queryParams === this.queryParams) { + this.highlighted = true; + this.cdRef.markForCheck(); + return; + } + this.highlighted = true; this.cdRef.markForCheck(); return; @@ -92,4 +106,12 @@ export class SideNavItemComponent implements OnInit { this.cdRef.markForCheck(); } + openLink() { + if (Object.keys(this.queryParams).length === 0) { + this.router.navigateByUrl(this.link!); + return + } + this.router.navigateByUrl(this.link + '?' + this.queryParams); + } + } diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index c0ee353c1..c6f6b70d0 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -1,38 +1,74 @@
- - - - - + + + + + - - - - - - - - - - - - - - -
+ + + +
- - + +
-
- - - +
+ + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
[] = []; + cachedData: SideNavStream[] | null = null; + actions: ActionItem[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}]; - filterQuery: string = ''; - filterLibrary = (library: Library) => { - return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0; - } - constructor(private libraryService: LibraryService, + filterQuery: string = ''; + filterLibrary = (stream: SideNavStream) => { + return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0; + } + showAll: boolean = false; + totalSize = 0; + protected readonly SideNavStreamType = SideNavStreamType; + + private showAllSubject = new BehaviorSubject(false); + showAll$ = this.showAllSubject.asObservable(); + + private loadDataSubject = new ReplaySubject(); + loadData$ = this.loadDataSubject.asObservable(); + + loadDataOnInit$: Observable = this.loadData$.pipe( + switchMap(() => { + if (this.cachedData != null) { + return of(this.cachedData); + } + return this.navService.getSideNavStreams().pipe( + map(data => { + this.cachedData = data; // Cache the data after initial load + return data; + }) + ); + }) + ); + + navStreams$ = merge( + this.showAll$.pipe( + startWith(false), + distinctUntilChanged(), + tap(showAll => this.showAll = showAll), + switchMap(showAll => + showAll + ? this.loadDataOnInit$.pipe( + tap(d => this.totalSize = d.length), + ) + : this.loadDataOnInit$.pipe( + tap(d => this.totalSize = d.length), + map(d => d.slice(0, 10)) + ) + ), + takeUntilDestroyed(this.destroyRef), + ), this.messageHub.messages$.pipe( + filter(event => event.event === EVENTS.LibraryModified || event.event === EVENTS.SideNavUpdate), + tap(() => { + this.cachedData = null; // Reset cached data to null to get latest + }), + switchMap(() => { + if (this.showAll) return this.loadDataOnInit$; + else return this.loadDataOnInit$.pipe(map(d => d.slice(0, 10))) + }), // Reload data when events occur + takeUntilDestroyed(this.destroyRef), + ) + ).pipe( + startWith(null), + filter(data => data !== null), + takeUntilDestroyed(this.destroyRef), + ); + + + constructor( public utilityService: UtilityService, private messageHub: MessageHubService, - private actionFactoryService: ActionFactoryService, private actionService: ActionService, + private actionService: ActionService, public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef, private ngbModal: NgbModal, private imageService: ImageService, public readonly accountService: AccountService) { @@ -74,20 +135,7 @@ export class SideNavComponent implements OnInit { ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (!user) return; - this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => { - this.libraries = libraries; - this.cdRef.markForCheck(); - }); - this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); - this.cdRef.markForCheck(); - }); - - // TODO: Investigate this, as it might be expensive - this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => { - this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => { - this.libraries = [...libraries]; - this.cdRef.markForCheck(); - }); + this.loadDataSubject.next(); }); } @@ -112,10 +160,8 @@ export class SideNavComponent implements OnInit { handleHomeActions() { this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'}); - // TODO: If on /, then refresh the page layout } - importCbl() { this.ngbModal.open(ImportCblModalComponent, {size: 'xl'}); } @@ -141,8 +187,17 @@ export class SideNavComponent implements OnInit { return null; } + toggleNavBar() { this.navService.toggleSideNav(); } + showMore() { + this.showAllSubject.next(true); + } + + showLess() { + this.showAllSubject.next(false); + } + } diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html new file mode 100644 index 000000000..05f914874 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html @@ -0,0 +1,45 @@ + +
+
+
+ + {{item.name | streamName }} + + + {{item.name}} + + + + +
+
+
+ {{t('provided')}} + + + {{t('library')}} + {{t('smart-filter')}} + {{t('external-source')}} + + +
+ +
+
+
+
diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.scss b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.scss new file mode 100644 index 000000000..27e2360d0 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.scss @@ -0,0 +1,8 @@ +.list-item { + height: 60px; + max-height: 60px; +} + +.meta { + display: flex; +} diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts new file mode 100644 index 000000000..18b3728ff --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts @@ -0,0 +1,21 @@ +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {SideNavStream} from "../../../_models/sidenav/sidenav-stream"; +import {StreamNamePipe} from "../../../pipe/stream-name.pipe"; +import {TranslocoDirective} from "@ngneat/transloco"; +import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum"; + +@Component({ + selector: 'app-sidenav-stream-list-item', + standalone: true, + imports: [CommonModule, StreamNamePipe, TranslocoDirective], + templateUrl: './sidenav-stream-list-item.component.html', + styleUrls: ['./sidenav-stream-list-item.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SidenavStreamListItemComponent { + @Input({required: true}) item!: SideNavStream; + @Input({required: true}) position: number = 0; + @Output() hide: EventEmitter = new EventEmitter(); + protected readonly SideNavStreamType = SideNavStreamType; +} diff --git a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html index c87efe976..00fbf43e4 100644 --- a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html +++ b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html @@ -12,7 +12,7 @@
-
+
  • {{item}}
  • -
+