From 568ea9fd3a41bbdc08a6bf4ccb7fd695530ff21a Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 16 Feb 2022 07:12:38 -0800 Subject: [PATCH] Custom Theme Support (#1077) * Started the migration to bootstrap 5. Introduced a breakpoint system that bootstrap reflects for our screens. * sr only migrated * mr/ml -> me/ms * pl/pr -> ps/pe * btn-block * removed input-group-append * Added form-label to all labels * Added some style overrides for inputs * Replaced form-group with mb-3 * Ignore journal files * Update media to d-flex/flex-grow-1 * Fixed reading list detail page * For develop builds, don't inline critical styles * Fixed some downstream security issues * Fixed a layout issue in series detail * Fixed issue with btn-light not having background color. Updated layout for series detail metadata * Cleaned up nav search * Laid out the organization for custom theme components. Update _inputs.scss with variable overrides and depending on theme, it will just work. * Lots of theming work * Added inputs to the theme page * Login and input placeholder changes - Fixed login screen centering issue on all devices - Changed the format of the login screen - Change the input placeholder color * Added checkbox styles * Refactored tagbadges and removed some ngdeep selectors * Added nav bar component and refactored some styles into event widget * Cleaned nav events again and made dedicated popover body * Finished pagination component * Fixed up some styles with buttons * refactored dropdown component * Update accordion component * Refactored breadcrumbs and rating star. Fixed a missing style for cards * Fixed some styling issues on person badge, added modal component, and some global styles * Finished moving everything within dark to component files * Fixed up filter buttons, move card styles into a component theme, fixed slider style * Refactored library card and grouped typeahead * Updated normal typeahead component and reduced amount of ngdeep selector * Refactored grid breakpoints to be available by css variable, but it's hardcoded into the app * Ensure breakpoints are defined per theme * Fixed up some styling overrides and customization for nav links and alt button * Removed some deep styles, moved css out of splash container and brough back labels for login page * Finished css variable refactor * Refactored all the theme variable definitions into files for each theme. * Added back bootstrap overrides * Added a note about bootstrap theme colors being not-possible to swap out at runtime * Cleaned up some dead code * Implemented the ability to set a custom theme on the site. Cleaned up misc code throughout. * Additional changes - Fixed nav where "kavita" was not hiding correctly on small viewports - Fixed search bar to make the behavior more consistent - Fixed accordion buttons - Changed accordion buttons to be more responsive - Added radio button colors - Fixed radios on theme test page - Changed login and reset password card layouts to be more consistent. - Added primary color shade for when darker shading is needed. * Built a basic site, allow the user to apply different themes, refactored nav service code out. * Implemented the ability update a user's theme * Added unit tests for Scan and Get Content in SiteThemeService. * Fixed a bug in the login code and Pref code which wasn't joining on SiteTheme table. Wrote Unit tests and the UI component to manage current theme. * Implemented scan so that it manages custom themes with unit tests * Component updates - Repositioning style ordering - Adding indicator override - Adding select styles * SignlaR integration, some fixes when creating custom entities, one single migration. Just login functionality left. * More ui updated - Added .no-hover to prevent hover on elements where not needed - Changed all selects I could find to appropriate class - Changed up nav tabs to work more like bootstrap tabs than pills - Added padding to top of some containers to make styles consistent - Added ability to change navbar fontawesome icon colors - removed some unecessary inline styling - Changed radio button to appropriate class - Toned down primate color, a bit too bright for dark theme. - Added ability to change button fontawesome icon color * nav-tab fix for series-detail * Added themes folder to gitignore * Adding card overlay * Fixing up light theme * Everything is done. Only bug is that color-scheme isn't being set properly from css variable. * Checkboxes have pointer by default. Confirm/Confirm email use default (dark) theme by default * Fixed an error where color-scheme wasn't reflecting correctly on themes on first load * Fixed user preferences not available on login * Changing dual radios to switches and color tweaks * disabled primary APCA fix * button APCA fixes * Fixed some timing issues with first load and image service * Fixed swiper issues from upgrade * Changed themes to be scss files again and adjusted Seed code * Migrated carousel to css variables. Fixed a broken animation for search. * Cleaned up some backend smells * Fixed white border outline on nav tabs, added some variables for header * Nav bar has been css variable-ified * Added some basic eink stuff to make the app useable Co-authored-by: Robbie Davis --- .gitignore | 2 + API.Tests/Services/SiteThemeServiceTests.cs | 264 ++++ API/API.csproj | 4 + API/Controllers/AccountController.cs | 31 +- API/Controllers/ThemeController.cs | 64 + API/Controllers/UsersController.cs | 3 +- API/DTOs/Theme/SiteThemeDto.cs | 30 + API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs | 6 + API/DTOs/UserDto.cs | 4 +- API/DTOs/UserPreferencesDto.cs | 5 +- API/Data/DataContext.cs | 1 + .../20220215163317_SiteTheme.Designer.cs | 1391 +++++++++++++++++ .../Migrations/20220215163317_SiteTheme.cs | 79 + .../Migrations/DataContextModelSnapshot.cs | 44 +- API/Data/Repositories/SiteThemeRepository.cs | 107 ++ API/Data/Repositories/UserRepository.cs | 15 +- API/Data/Seed.cs | 46 + API/Data/UnitOfWork.cs | 2 + API/Entities/AppUserPreferences.cs | 6 +- API/Entities/Enums/Theme/ThemeProvider.cs | 17 + API/Entities/SiteTheme.cs | 37 + .../ApplicationServiceExtensions.cs | 1 + API/Helpers/AutoMapperProfiles.cs | 10 +- API/Program.cs | 1 + API/Services/DirectoryService.cs | 3 + API/Services/TaskScheduler.cs | 12 +- API/Services/Tasks/SiteThemeService.cs | 163 ++ API/SignalR/MessageFactory.cs | 16 + API/SignalR/SignalREvents.cs | 5 + INSTALL.txt | 3 +- UI/Web/angular.json | 2 +- UI/Web/package-lock.json | 30 +- UI/Web/package.json | 7 +- .../review-series-modal.component.html | 16 +- .../events/site-theme-progress-event.ts | 7 + .../app/_models/preferences/preferences.ts | 3 +- .../src/app/_models/preferences/site-theme.ts | 22 + UI/Web/src/app/_services/account.service.ts | 9 +- UI/Web/src/app/_services/image.service.ts | 10 +- .../src/app/_services/message-hub.service.ts | 15 +- UI/Web/src/app/_services/nav.service.ts | 33 +- .../directory-picker.component.html | 27 +- .../library-access-modal.component.html | 4 +- .../library-editor-modal.component.html | 20 +- .../reset-password-modal.component.html | 8 +- .../admin/changelog/changelog.component.html | 6 +- .../admin/dashboard/dashboard.component.html | 2 +- .../admin/dashboard/dashboard.component.scss | 3 + .../admin/edit-user/edit-user.component.html | 18 +- .../invite-user/invite-user.component.html | 12 +- .../manage-library.component.html | 12 +- .../manage-settings.component.html | 94 +- .../manage-system.component.html | 8 +- .../manage-users/manage-users.component.html | 28 +- UI/Web/src/app/app-routing.module.ts | 2 + UI/Web/src/app/app.component.html | 2 +- UI/Web/src/app/app.component.ts | 31 +- UI/Web/src/app/app.module.ts | 2 + .../book-reader/book-reader.component.html | 38 +- .../book-reader/book-reader.component.ts | 7 +- .../bookmarks-modal.component.html | 6 +- .../bulk-add-to-collection.component.html | 16 +- .../card-details-modal.component.html | 24 +- .../edit-collection-tags.component.html | 10 +- .../edit-series-modal.component.html | 64 +- .../cards/bookmark/bookmark.component.html | 4 +- .../bulk-operations.component.html | 2 +- .../bulk-operations.component.scss | 14 +- .../card-detail-layout.component.html | 168 +- .../card-detail-layout.component.scss | 9 - .../cards/card-item/card-item.component.html | 11 +- .../cards/card-item/card-item.component.scss | 31 +- .../chapter-metadata-detail.component.html | 24 +- .../cover-image-chooser.component.html | 23 +- .../cover-image-chooser.component.scss | 5 +- .../cards/file-info/file-info.component.html | 2 +- .../library-card/library-card.component.scss | 2 - .../carousel-reel.component.html | 6 +- .../carousel-reel.component.scss | 43 +- .../carousel-reel/carousel-reel.component.ts | 29 +- .../collection-detail.component.html | 8 +- .../grouped-typeahead.component.html | 8 +- .../grouped-typeahead.component.scss | 132 +- .../infinite-scroller.component.html | 4 +- .../manga-reader/manga-reader.component.html | 28 +- .../manga-reader/manga-reader.component.scss | 28 +- .../nav-events-toggle.component.html | 4 +- .../nav-events-toggle.component.scss | 36 +- .../nav-events-toggle.component.ts | 3 +- .../app/nav-header/nav-header.component.html | 212 ++- .../app/nav-header/nav-header.component.scss | 34 +- .../app/nav-header/nav-header.component.ts | 18 +- .../add-to-list-modal.component.html | 16 +- .../add-to-list-modal.component.scss | 7 +- .../edit-reading-list-modal.component.html | 14 +- .../dragable-ordered-list.component.html | 8 +- .../reading-list-detail.component.html | 24 +- ...-to-account-migration-modal.component.html | 16 +- .../confirm-email.component.html | 16 +- .../confirm-email/confirm-email.component.ts | 8 +- .../confirm-migration-email.component.ts | 4 +- .../confirm-reset-password.component.html | 8 +- .../register/register.component.html | 16 +- .../reset-password.component.html | 8 +- .../reset-password.component.scss | 8 + .../splash-container.component.html | 2 +- .../splash-container.component.scss | 43 +- .../series-detail.component.html | 52 +- .../series-detail.component.scss | 16 +- .../series-metadata-detail.component.html | 68 +- .../series-metadata-detail.component.scss | 6 - .../app/shared/_services/utility.service.ts | 2 - .../circular-loader.component.html | 2 +- .../circular-loader.component.scss | 15 +- .../confirm-dialog.component.html | 4 +- .../person-badge/person-badge.component.html | 10 +- .../person-badge/person-badge.component.scss | 17 +- .../shared/read-more/read-more.component.scss | 12 +- .../shared/tag-badge/tag-badge.component.scss | 20 +- .../update-notification-modal.component.html | 4 +- .../app/theme-test/theme-test.component.html | 134 ++ .../app/theme-test/theme-test.component.scss | 0 .../app/theme-test/theme-test.component.ts | 32 + UI/Web/src/app/theme.service.ts | 133 ++ .../app/typeahead/typeahead.component.html | 2 +- .../app/typeahead/typeahead.component.scss | 20 +- .../app/user-login/user-login.component.html | 20 +- .../app/user-login/user-login.component.scss | 100 +- .../api-key/api-key.component.html | 10 +- .../series-bookmarks.component.html | 14 +- .../theme-manager.component.html | 28 + .../theme-manager.component.scss | 0 .../theme-manager/theme-manager.component.ts | 74 + .../user-preferences.component.html | 189 ++- .../user-preferences.component.scss | 54 +- .../user-preferences.component.ts | 12 +- .../app/user-settings/user-settings.module.ts | 4 +- UI/Web/src/styles.scss | 110 +- UI/Web/src/theme/_colors.scss | 59 - UI/Web/src/theme/_toastr.scss | 8 +- UI/Web/src/theme/_variables.scss | 20 + UI/Web/src/theme/components/_accordion.scss | 24 + UI/Web/src/theme/components/_anchors.scss | 15 + UI/Web/src/theme/components/_breadcrumb.scss | 9 + UI/Web/src/theme/components/_buttons.scss | 71 + UI/Web/src/theme/components/_card.scss | 14 + UI/Web/src/theme/components/_checkbox.scss | 15 + UI/Web/src/theme/components/_dropdown.scss | 15 + UI/Web/src/theme/components/_input.scss | 24 + UI/Web/src/theme/components/_list.scss | 20 + UI/Web/src/theme/components/_modal.scss | 4 + UI/Web/src/theme/components/_nav.scss | 45 + UI/Web/src/theme/components/_navbar.scss | 9 + UI/Web/src/theme/components/_pagination.scss | 21 + UI/Web/src/theme/components/_popover.scss | 5 + UI/Web/src/theme/components/_radios.scss | 7 + UI/Web/src/theme/components/_ratingstar.scss | 11 + UI/Web/src/theme/components/_selects.scss | 24 + UI/Web/src/theme/components/_slider.scss | 51 + UI/Web/src/theme/components/_tagbadge.scss | 5 + UI/Web/src/theme/components/_toast.scss | 15 + UI/Web/src/theme/dark.scss | 226 --- UI/Web/src/theme/themes/dark.scss | 169 ++ UI/Web/src/theme/themes/eink.scss | 114 ++ UI/Web/src/theme/themes/light.scss | 112 ++ UI/Web/src/theme/utilities/_animations.scss | 13 + UI/Web/src/theme/utilities/_global.scss | 15 + UI/Web/src/theme/utilities/_utilities.scss | 16 + 168 files changed, 4710 insertions(+), 1666 deletions(-) create mode 100644 API.Tests/Services/SiteThemeServiceTests.cs create mode 100644 API/Controllers/ThemeController.cs create mode 100644 API/DTOs/Theme/SiteThemeDto.cs create mode 100644 API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs create mode 100644 API/Data/Migrations/20220215163317_SiteTheme.Designer.cs create mode 100644 API/Data/Migrations/20220215163317_SiteTheme.cs create mode 100644 API/Data/Repositories/SiteThemeRepository.cs create mode 100644 API/Entities/Enums/Theme/ThemeProvider.cs create mode 100644 API/Entities/SiteTheme.cs create mode 100644 API/Services/Tasks/SiteThemeService.cs create mode 100644 UI/Web/src/app/_models/events/site-theme-progress-event.ts create mode 100644 UI/Web/src/app/_models/preferences/site-theme.ts create mode 100644 UI/Web/src/app/theme-test/theme-test.component.html create mode 100644 UI/Web/src/app/theme-test/theme-test.component.scss create mode 100644 UI/Web/src/app/theme-test/theme-test.component.ts create mode 100644 UI/Web/src/app/theme.service.ts create mode 100644 UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html create mode 100644 UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss create mode 100644 UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts delete mode 100644 UI/Web/src/theme/_colors.scss create mode 100644 UI/Web/src/theme/_variables.scss create mode 100644 UI/Web/src/theme/components/_accordion.scss create mode 100644 UI/Web/src/theme/components/_anchors.scss create mode 100644 UI/Web/src/theme/components/_breadcrumb.scss create mode 100644 UI/Web/src/theme/components/_buttons.scss create mode 100644 UI/Web/src/theme/components/_card.scss create mode 100644 UI/Web/src/theme/components/_checkbox.scss create mode 100644 UI/Web/src/theme/components/_dropdown.scss create mode 100644 UI/Web/src/theme/components/_input.scss create mode 100644 UI/Web/src/theme/components/_list.scss create mode 100644 UI/Web/src/theme/components/_modal.scss create mode 100644 UI/Web/src/theme/components/_nav.scss create mode 100644 UI/Web/src/theme/components/_navbar.scss create mode 100644 UI/Web/src/theme/components/_pagination.scss create mode 100644 UI/Web/src/theme/components/_popover.scss create mode 100644 UI/Web/src/theme/components/_radios.scss create mode 100644 UI/Web/src/theme/components/_ratingstar.scss create mode 100644 UI/Web/src/theme/components/_selects.scss create mode 100644 UI/Web/src/theme/components/_slider.scss create mode 100644 UI/Web/src/theme/components/_tagbadge.scss create mode 100644 UI/Web/src/theme/components/_toast.scss delete mode 100644 UI/Web/src/theme/dark.scss create mode 100644 UI/Web/src/theme/themes/dark.scss create mode 100644 UI/Web/src/theme/themes/eink.scss create mode 100644 UI/Web/src/theme/themes/light.scss create mode 100644 UI/Web/src/theme/utilities/_animations.scss create mode 100644 UI/Web/src/theme/utilities/_global.scss create mode 100644 UI/Web/src/theme/utilities/_utilities.scss diff --git a/.gitignore b/.gitignore index 8bc302ff8..c8d68977f 100644 --- a/.gitignore +++ b/.gitignore @@ -510,11 +510,13 @@ UI/Web/dist/ /API/config/backups/ /API/config/cache/ /API/config/temp/ +/API/config/themes/ /API/config/stats/ /API/config/bookmarks/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal +/API/config/kavita.db-journal /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs new file mode 100644 index 000000000..a9198f26f --- /dev/null +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Enums.Theme; +using API.Helpers; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SiteThemeServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IHubContext _messageHub = Substitute.For>(); + + private readonly DbConnection _connection; + private readonly DataContext _context; + private readonly IUnitOfWork _unitOfWork; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; + private const string SiteThemeDirectory = "C:/kavita/config/themes/"; + + public SiteThemeServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; + + _context.ServerSetting.Update(setting); + + _context.AppUser.Add(new AppUser() + { + UserName = "Joe", + UserPreferences = new AppUserPreferences + { + Theme = Seed.DefaultThemes[1] + } + }); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory(SiteThemeDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } + + private async Task ResetDb() + { + _context.SiteTheme.RemoveRange(_context.SiteTheme); + await _context.SaveChangesAsync(); + } + + #endregion + + [Fact] + public async Task Scan_ShouldFindCustomFile() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + } + + [Fact] + public async Task Scan_ShouldOnlyInsertOnceOnSecondScan() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + + await siteThemeService.Scan(); + + var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => + API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom"))); + Assert.Single(customThemes); + } + + [Fact] + public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + + filesystem.RemoveFile($"{SiteThemeDirectory}custom.css"); + await siteThemeService.Scan(); + + var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => + API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom"))); + + Assert.Empty(customThemes); + } + + [Fact] + public async Task GetContent_ShouldReturnContent() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); + Assert.NotNull(content); + Assert.NotEmpty(content); + Assert.Equal("123", content); + } + + [Fact] + public async Task UpdateDefault_ShouldHaveOneDefault() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); + + await siteThemeService.UpdateDefault(customTheme.Id); + + + + Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); + } + + [Fact] + public async Task UpdateDefault_ShouldThrowOnInvalidId() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + + + var ex = await Assert.ThrowsAsync(async () => await siteThemeService.UpdateDefault(10)); + Assert.Equal("Theme file missing or invalid", ex.Message); + + } + + +} diff --git a/API/API.csproj b/API/API.csproj index 42e0d1107..a95863aa7 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -310,4 +310,8 @@ + + + + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 9765f700e..913f53133 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -106,7 +106,10 @@ namespace API.Controllers { UserName = registerDto.Username, Email = registerDto.Email, - UserPreferences = new AppUserPreferences(), + UserPreferences = new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }, ApiKey = HashUtil.ApiKey() }; @@ -179,22 +182,23 @@ namespace API.Controllers // Update LastActive on account user.LastActive = DateTime.Now; - user.UserPreferences ??= new AppUserPreferences(); + user.UserPreferences ??= new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }; _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); - return new UserDto - { - Username = user.UserName, - Email = user.Email, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), - ApiKey = user.ApiKey, - Preferences = _mapper.Map(user.UserPreferences) - }; + var dto = _mapper.Map(user); + dto.Token = await _tokenService.CreateToken(user); + dto.RefreshToken = await _tokenService.CreateRefreshToken(user); + var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName); + pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + dto.Preferences = _mapper.Map(pref); + return dto; } [HttpPost("refresh-token")] @@ -358,7 +362,10 @@ namespace API.Controllers UserName = dto.Email, Email = dto.Email, ApiKey = HashUtil.ApiKey(), - UserPreferences = new AppUserPreferences() + UserPreferences = new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + } }; try diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs new file mode 100644 index 000000000..f6775d2dc --- /dev/null +++ b/API/Controllers/ThemeController.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Theme; +using API.Services; +using API.Services.Tasks; +using Kavita.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class ThemeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ISiteThemeService _siteThemeService; + private readonly ITaskScheduler _taskScheduler; + + public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler) + { + _unitOfWork = unitOfWork; + _siteThemeService = siteThemeService; + _taskScheduler = taskScheduler; + } + + [HttpGet] + public async Task>> GetThemes() + { + return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos()); + } + + [Authorize("RequireAdminRole")] + [HttpPost("scan")] + public ActionResult Scan() + { + _taskScheduler.ScanSiteThemes(); + return Ok(); + } + + [Authorize("RequireAdminRole")] + [HttpPost("update-default")] + public async Task UpdateDefault(UpdateDefaultSiteThemeDto dto) + { + await _siteThemeService.UpdateDefault(dto.ThemeId); + return Ok(); + } + + /// + /// Returns css content to the UI. UI is expected to escape the content + /// + /// + [HttpGet("download-content")] + public async Task> GetThemeContent(int themeId) + { + try + { + return Ok(await _siteThemeService.GetContent(themeId)); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } +} diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index dd6e975ab..3569aca2a 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -78,7 +78,8 @@ namespace API.Controllers existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode; existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode; + existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; + existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); _unitOfWork.UserRepository.Update(existingPreferences); diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs new file mode 100644 index 000000000..e8b0460f9 --- /dev/null +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -0,0 +1,30 @@ +using System; +using API.Entities.Enums.Theme; +using API.Services; + +namespace API.DTOs.Theme; + +public class SiteThemeDto +{ + public int Id { get; set; } + /// + /// Name of the Theme + /// + public string Name { get; set; } + /// + /// File path to the content. Stored under . + /// Must be a .css file + /// + public string FileName { get; set; } + /// + /// Only one theme can have this. Will auto-set this as default for new user accounts + /// + public bool IsDefault { get; set; } + /// + /// Where did the theme come from + /// + public ThemeProvider Provider { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public string Selector => "bg-" + Name.ToLower(); +} diff --git a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs new file mode 100644 index 000000000..d4bdb8e09 --- /dev/null +++ b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.Theme; + +public class UpdateDefaultSiteThemeDto +{ + public int ThemeId { get; set; } +} diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 7a7a234e7..dc6fc8b43 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -5,8 +5,8 @@ namespace API.DTOs { public string Username { get; init; } public string Email { get; init; } - public string Token { get; init; } - public string RefreshToken { get; init; } + public string Token { get; set; } + public string RefreshToken { get; set; } public string ApiKey { get; init; } public UserPreferencesDto Preferences { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index c36c9d146..6881cd0ae 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using API.Entities; +using API.Entities.Enums; namespace API.DTOs { @@ -16,6 +17,6 @@ namespace API.DTOs public string BookReaderFontFamily { get; set; } public bool BookReaderTapToPaginate { get; set; } public ReadingDirection BookReaderReadingDirection { get; set; } - public bool SiteDarkMode { get; set; } + public SiteTheme Theme { get; set; } } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index c1e100d48..6822467a8 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -40,6 +40,7 @@ namespace API.Data public DbSet Person { get; set; } public DbSet Genre { get; set; } public DbSet Tag { get; set; } + public DbSet SiteTheme { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs new file mode 100644 index 000000000..43b538c9a --- /dev/null +++ b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs @@ -0,0 +1,1391 @@ +// +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("20220215163317_SiteTheme")] + partial class SiteTheme + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .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("FileName") + .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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + 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("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .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.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.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", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.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.Series", b => + { + 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("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + 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("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220215163317_SiteTheme.cs b/API/Data/Migrations/20220215163317_SiteTheme.cs new file mode 100644 index 000000000..e2f519f8b --- /dev/null +++ b/API/Data/Migrations/20220215163317_SiteTheme.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SiteTheme : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SiteDarkMode", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "ThemeId", + table: "AppUserPreferences", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "SiteTheme", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + FileName = table.Column(type: "TEXT", nullable: true), + IsDefault = table.Column(type: "INTEGER", nullable: false), + Provider = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SiteTheme", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserPreferences_ThemeId", + table: "AppUserPreferences", + column: "ThemeId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserPreferences_SiteTheme_ThemeId", + table: "AppUserPreferences", + column: "ThemeId", + principalTable: "SiteTheme", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserPreferences_SiteTheme_ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.DropTable( + name: "SiteTheme"); + + migrationBuilder.DropIndex( + name: "IX_AppUserPreferences_ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "SiteDarkMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c0aefbcd2..ff8d50df9 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", "6.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -198,7 +198,7 @@ namespace API.Data.Migrations b.Property("ScalingOption") .HasColumnType("INTEGER"); - b.Property("SiteDarkMode") + b.Property("ThemeId") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -206,6 +206,8 @@ namespace API.Data.Migrations b.HasIndex("AppUserId") .IsUnique(); + b.HasIndex("ThemeId"); + b.ToTable("AppUserPreferences"); }); @@ -687,6 +689,38 @@ namespace API.Data.Migrations b.ToTable("ServerSetting"); }); + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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") @@ -967,7 +1001,13 @@ namespace API.Data.Migrations .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 => diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs new file mode 100644 index 000000000..a95fcda23 --- /dev/null +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Theme; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface ISiteThemeRepository +{ + void Add(SiteTheme theme); + void Remove(SiteTheme theme); + void Update(SiteTheme siteTheme); + Task> GetThemeDtos(); + Task GetThemeDto(int themeId); + Task GetThemeDtoByName(string themeName); + Task GetDefaultTheme(); + Task> GetThemes(); + + Task GetThemeById(int themeId); +} + +public class SiteThemeRepository : ISiteThemeRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SiteThemeRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(SiteTheme theme) + { + _context.Add(theme); + } + + public void Remove(SiteTheme theme) + { + _context.Remove(theme); + } + + public void Update(SiteTheme siteTheme) + { + _context.Entry(siteTheme).State = EntityState.Modified; + } + + public async Task> GetThemeDtos() + { + return await _context.SiteTheme + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetThemeDtoByName(string themeName) + { + return await _context.SiteTheme + .Where(t => t.Name.Equals(themeName)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + /// + /// Returns default theme, if the default theme is not available, returns the dark theme + /// + /// + public async Task GetDefaultTheme() + { + var result = await _context.SiteTheme + .Where(t => t.IsDefault) + .SingleOrDefaultAsync(); + + if (result == null) + { + return await _context.SiteTheme + .Where(t => t.NormalizedName == "dark") + .SingleOrDefaultAsync(); + } + + return result; + } + + public async Task> GetThemes() + { + return await _context.SiteTheme + .ToListAsync(); + } + + public async Task GetThemeById(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .SingleOrDefaultAsync(); + } + + public async Task GetThemeDto(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index b926abe9c..e41849c92 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -55,6 +55,7 @@ public interface IUserRepository Task GetUserByEmailAsync(string email); Task> GetAllUsers(); + Task> GetAllPreferencesByThemeAsync(int themeId); } public class UserRepository : IUserRepository @@ -227,6 +228,15 @@ public class UserRepository : IUserRepository return await _context.AppUser.ToListAsync(); } + public async Task> GetAllPreferencesByThemeAsync(int themeId) + { + return await _context.AppUserPreferences + .Include(p => p.Theme) + .Where(p => p.Theme.Id == themeId) + .AsSplitQuery() + .ToListAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -244,7 +254,8 @@ public class UserRepository : IUserRepository public async Task GetUserRatingAsync(int seriesId, int userId) { - return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) + return await _context.AppUserRating + .Where(r => r.SeriesId == seriesId && r.AppUserId == userId) .SingleOrDefaultAsync(); } @@ -252,6 +263,8 @@ public class UserRepository : IUserRepository { return await _context.AppUserPreferences .Include(p => p.AppUser) + .Include(p => p.Theme) + .AsSplitQuery() .SingleOrDefaultAsync(p => p.AppUser.UserName == username); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 3dd8ecc5f..071d1eaae 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Reflection; @@ -6,6 +7,7 @@ using System.Threading.Tasks; using API.Constants; using API.Entities; using API.Entities.Enums; +using API.Entities.Enums.Theme; using API.Services; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -21,6 +23,34 @@ namespace API.Data /// public static IList DefaultSettings; + public static readonly IList DefaultThemes = new List + { + new() + { + Name = "Dark", + NormalizedName = Parser.Parser.Normalize("Dark"), + Provider = ThemeProvider.System, + FileName = "dark.scss", + IsDefault = true, + }, + new() + { + Name = "Light", + NormalizedName = Parser.Parser.Normalize("Light"), + Provider = ThemeProvider.System, + FileName = "light.scss", + IsDefault = false, + }, + new() + { + Name = "E-Ink", + NormalizedName = Parser.Parser.Normalize("E-Ink"), + Provider = ThemeProvider.System, + FileName = "eink.scss", + IsDefault = false, + }, + }; + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -41,6 +71,22 @@ namespace API.Data } } + public static async Task SeedThemes(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + foreach (var theme in DefaultThemes) + { + var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name)); + if (existing == null) + { + await context.SiteTheme.AddAsync(theme); + } + } + + await context.SaveChangesAsync(); + } + 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 82046ca2a..fb3e28c07 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -21,6 +21,7 @@ public interface IUnitOfWork IPersonRepository PersonRepository { get; } IGenreRepository GenreRepository { get; } ITagRepository TagRepository { get; } + ISiteThemeRepository SiteThemeRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -56,6 +57,7 @@ public class UnitOfWork : IUnitOfWork public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); public ITagRepository TagRepository => new TagRepository(_context, _mapper); + public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 01587431b..38f95cf42 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -58,11 +58,11 @@ namespace API.Entities /// Book Reader Option: What direction should the next/prev page buttons go /// public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// - /// UI Site Global Setting: Whether the UI should render in Dark mode or not. + /// UI Site Global Setting: The UI theme the user should use. /// - public bool SiteDarkMode { get; set; } = true; + /// Should default to Dark + public SiteTheme Theme { get; set; } diff --git a/API/Entities/Enums/Theme/ThemeProvider.cs b/API/Entities/Enums/Theme/ThemeProvider.cs new file mode 100644 index 000000000..45af2d94b --- /dev/null +++ b/API/Entities/Enums/Theme/ThemeProvider.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.Theme; + +public enum ThemeProvider +{ + /// + /// Theme is provided by System + /// + [Description("System")] + System = 1, + /// + /// Theme is provided by the User (ie it's custom) + /// + [Description("User")] + User = 2 +} diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs new file mode 100644 index 000000000..87ebe95b1 --- /dev/null +++ b/API/Entities/SiteTheme.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums.Theme; +using API.Entities.Interfaces; +using API.Services; + +namespace API.Entities; +/// +/// Represents a set of css overrides the user can upload to Kavita and will load into webui +/// +public class SiteTheme : IEntityDate +{ + public int Id { get; set; } + /// + /// Name of the Theme + /// + public string Name { get; set; } + /// + /// Normalized name for lookups + /// + public string NormalizedName { get; set; } + /// + /// File path to the content. Stored under . + /// Must be a .css file + /// + public string FileName { get; set; } + /// + /// Only one theme can have this. Will auto-set this as default for new user accounts + /// + public bool IsDefault { get; set; } + /// + /// Where did the theme come from + /// + public ThemeProvider Provider { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f11f0a8d1..146647393 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -39,6 +39,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6ac4aeee5..0b81f2d7f 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -7,6 +7,7 @@ using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Search; using API.DTOs.Settings; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -119,10 +120,14 @@ namespace API.Helpers opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember(dest => dest.Theme, + opt => + opt.MapFrom(src => src.Theme)); - CreateMap(); - CreateMap(); CreateMap(); @@ -146,6 +151,7 @@ namespace API.Helpers CreateMap(); + CreateMap, ServerSettingDto>() .ConvertUsing(); } diff --git a/API/Program.cs b/API/Program.cs index 3a0d9ab25..b81fe61d5 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -77,6 +77,7 @@ namespace API await Seed.SeedRoles(roleManager); await Seed.SeedSettings(context, directoryService); + await Seed.SeedThemes(context); await Seed.SeedUserApiKeys(context); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 0edf51ffc..89981d43f 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -19,6 +19,7 @@ namespace API.Services string LogDirectory { get; } string TempDirectory { get; } string ConfigDirectory { get; } + string SiteThemeDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// @@ -64,6 +65,7 @@ namespace API.Services public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string SiteThemeDirectory { get; } private readonly ILogger _logger; private static readonly Regex ExcludeDirectories = new Regex( @@ -81,6 +83,7 @@ namespace API.Services TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); + SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); } /// diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 6c1d914cf..35dc332c6 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -22,6 +22,7 @@ public interface ITaskScheduler void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); + void ScanSiteThemes(); } public class TaskScheduler : ITaskScheduler { @@ -35,6 +36,7 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; + private readonly ISiteThemeService _siteThemeService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -42,7 +44,8 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, + ISiteThemeService siteThemeService) { _cacheService = cacheService; _logger = logger; @@ -53,6 +56,7 @@ public class TaskScheduler : ITaskScheduler _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; + _siteThemeService = siteThemeService; } public async Task ScheduleTasks() @@ -124,6 +128,12 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _statsService.Send()); } + public void ScanSiteThemes() + { + _logger.LogInformation("Starting Site Theme scan"); + BackgroundJob.Enqueue(() => _siteThemeService.Scan()); + } + #endregion #region UpdateTasks diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs new file mode 100644 index 000000000..e474adf06 --- /dev/null +++ b/API/Services/Tasks/SiteThemeService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums.Theme; +using API.SignalR; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; + +namespace API.Services.Tasks; + +public interface ISiteThemeService +{ + Task GetContent(int themeId); + Task Scan(); + Task UpdateDefault(int themeId); +} + +public class SiteThemeService : ISiteThemeService +{ + private readonly IDirectoryService _directoryService; + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub; + + public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext messageHub) + { + _directoryService = directoryService; + _unitOfWork = unitOfWork; + _messageHub = messageHub; + } + + /// + /// Given a themeId, return the content inside that file + /// + /// + /// + /// + public async Task GetContent(int themeId) + { + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); + if (theme == null) throw new KavitaException("Theme file missing or invalid"); + var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); + if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile)) + throw new KavitaException("Theme file missing or invalid"); + + return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile); + } + + /// + /// Scans the site theme directory for custom css files and updates what the system has on store + /// + public async Task Scan() + { + _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); + var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); + var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") + .Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList(); + + var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); + var totalThemesToIterate = themeFiles.Count; + var themeIteratedCount = 0; + + // First remove any files from allThemes that are User Defined and not on disk + var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList(); + foreach (var userTheme in userThemes) + { + var filepath = Parser.Parser.NormalizePath( + _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName)); + if (!_directoryService.FileSystem.File.Exists(filepath)) + { + // I need to do the removal different. I need to update all userpreferences to use DefaultTheme + allThemes.Remove(userTheme); + await RemoveTheme(userTheme); + + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(1, totalThemesToIterate, userTheme.FileName, 0F)); + } + } + + // Add new custom themes + var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList(); + foreach (var themeFile in themeFiles) + { + var themeName = + Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile)); + if (allThemeNames.Contains(themeName)) + { + themeIteratedCount += 1; + continue; + } + _unitOfWork.SiteThemeRepository.Add(new SiteTheme() + { + Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile), + NormalizedName = themeName, + FileName = _directoryService.FileSystem.Path.GetFileName(themeFile), + Provider = ThemeProvider.User, + IsDefault = false, + }); + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(themeIteratedCount, totalThemesToIterate, themeName, themeIteratedCount / (totalThemesToIterate * 1.0f))); + themeIteratedCount += 1; + } + + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(totalThemesToIterate, totalThemesToIterate, "", 1F)); + + } + + /// + /// Removes the theme and any references to it from Pref and sets them to the default at the time. + /// This commits to DB. + /// + /// + private async Task RemoveTheme(SiteTheme theme) + { + var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id); + var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + foreach (var pref in prefs) + { + pref.Theme = defaultTheme; + _unitOfWork.UserRepository.Update(pref); + } + _unitOfWork.SiteThemeRepository.Remove(theme); + await _unitOfWork.CommitAsync(); + } + + /// + /// Updates the themeId to the default theme, all others are marked as non-default + /// + /// + /// + /// If theme does not exist + public async Task UpdateDefault(int themeId) + { + try + { + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); + if (theme == null) throw new KavitaException("Theme file missing or invalid"); + + foreach (var siteTheme in await _unitOfWork.SiteThemeRepository.GetThemes()) + { + siteTheme.IsDefault = (siteTheme.Id == themeId); + _unitOfWork.SiteThemeRepository.Update(siteTheme); + } + + if (!_unitOfWork.HasChanges()) return; + await _unitOfWork.CommitAsync(); + } + catch (Exception) + { + await _unitOfWork.RollbackAsync(); + throw; + } + } +} diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index bf7c649bf..c2f661973 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using API.DTOs.Update; namespace API.SignalR @@ -160,5 +161,20 @@ namespace API.SignalR } }; } + + public static SignalRMessage SiteThemeProgressEvent(int themeIteratedCount, int totalThemesToIterate, string themeName, float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.SiteThemeProgress, + Body = new + { + TotalUpdates = totalThemesToIterate, + CurrentCount = themeIteratedCount, + ThemeName = themeName, + Progress = progress + } + }; + } } } diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 1da613455..7f9f44cf9 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -54,5 +54,10 @@ /// A cover was updated /// public const string CoverUpdate = "CoverUpdate"; + /// + /// A custom site theme was removed or added + /// + public const string SiteThemeProgress = "SiteThemeProgress"; + } } diff --git a/INSTALL.txt b/INSTALL.txt index 9119da82c..753236e9a 100644 --- a/INSTALL.txt +++ b/INSTALL.txt @@ -4,4 +4,5 @@ 3. Run Kavita executable. 4. Open localhost:5000 and setup your account and libraries in the UI. -If updating, copy everything but the config/ directory over. Restart Kavita. +How to Update +1. Copy everything but the config/ directory over. Restart Kavita. diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 10aefdb56..56bc8a3e1 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -49,7 +49,7 @@ "vendorChunk": true, "extractLicenses": false, "buildOptimizer": false, - "optimization": true, + "optimization": false, "namedChunks": true }, "configurations": { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index fe67970cf..85739725d 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2405,18 +2405,11 @@ } }, "@ng-bootstrap/ng-bootstrap": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-11.0.0.tgz", - "integrity": "sha512-qDnB0+jbpQ4wjXpM4NPRAtwmgTDUCjGavoeRDZHOvFfYvx/MBf1RTjZEqTJ1Yqq1pKP4BWpzxCgVTunfnpmsjA==", + "version": "12.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0-beta.4.tgz", + "integrity": "sha512-iOXZT4FLouAGJDRw4ruogyR+lg648nywNWKUxW7l+mtMC9i4kdpfo4beQ/nqb4Uq2zMDs9zj4MbKVI391+kMnA==", "requires": { "tslib": "^2.3.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" - } } }, "@ngtools/webpack": { @@ -2581,6 +2574,11 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==" }, + "@popperjs/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", + "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + }, "@schematics/angular": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-13.2.3.tgz", @@ -3841,9 +3839,9 @@ "dev": true }, "bootstrap": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.1.tgz", - "integrity": "sha512-0dj+VgI9Ecom+rvvpNZ4MUZJz8dcX7WCX+eTID9+/8HgOkv3dsRzi8BGeZJCQU6flWQVYxwTQnEZFrmJSEO7og==" + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz", + "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==" }, "bowser": { "version": "2.11.0", @@ -11848,9 +11846,9 @@ "dev": true }, "swiper": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.3.tgz", - "integrity": "sha512-mpw7v/Lkh48LQUxtJuFD+3Lls8LViNi3j1fbk45fNo9DXZxXK/e7NMixxS27OxvC5wx+5H3bet1O2pdjk7akBA==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.6.tgz", + "integrity": "sha512-Ssyu1+FeNATF/G8e84QG+ZUNtUOAZ5vngdgxzczh0oWZPhGUVgkdv+BoePUuaCXLAFXnwVpNjgLIcGnxMdmWPA==", "requires": { "dom7": "^4.0.4", "ssr-window": "^4.0.2" diff --git a/UI/Web/package.json b/UI/Web/package.json index 4eb73bd67..fd021a1c8 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -28,11 +28,12 @@ "@angular/router": "~13.2.2", "@fortawesome/fontawesome-free": "^6.0.0", "@microsoft/signalr": "^6.0.2", - "@ng-bootstrap/ng-bootstrap": "^11.0.0", + "@ng-bootstrap/ng-bootstrap": "^12.0.0-beta.4", "@ngx-lite/nav-drawer": "^0.4.7", "@ngx-lite/util": "0.0.1", + "@popperjs/core": "^2.11.2", "@types/file-saver": "^2.0.5", - "bootstrap": "^4.6.1", + "bootstrap": "^5.1.2", "bowser": "^2.11.0", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", @@ -41,7 +42,7 @@ "ngx-file-drop": "^13.0.0", "ngx-toastr": "^14.2.1", "rxjs": "~7.5.4", - "swiper": "^8.0.3", + "swiper": "^8.0.6", "tslib": "^2.3.1", "webpack-bundle-analyzer": "^4.5.0", "zone.js": "~0.11.4" diff --git a/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html b/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html index b6fbedd6c..cfb00e0a8 100644 --- a/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html +++ b/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html @@ -2,29 +2,29 @@