diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index d4edddf72..f12472c5b 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1569,6 +1569,61 @@ public class ReaderServiceTests Assert.Equal("Some Special Title", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress() + { + var series = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("230", false, new List(), 1), + //EntityFactory.CreateChapter("231", false, new List(), 1), (added later) + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + //EntityFactory.CreateChapter("14.9", false, new List(), 1), (added later) + }), + } + }; + _context.Series.Add(series); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + await readerService.MarkSeriesAsRead(user, 1); + await _context.SaveChangesAsync(); + + // Add 2 new unread series to the Series + series.Volumes[0].Chapters.Add(EntityFactory.CreateChapter("231", false, new List(), 1)); + series.Volumes[2].Chapters.Add(EntityFactory.CreateChapter("14.9", false, new List(), 1)); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + Assert.Equal("14.9", nextChapter.Range); + } + #endregion #region MarkChaptersUntilAsRead @@ -1702,5 +1757,126 @@ public class ReaderServiceTests #endregion + #region MarkSeriesAsRead + [Fact] + public async Task MarkSeriesAsReadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + }, + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await _context.SaveChangesAsync(); + + Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + } + + + #endregion + + #region MarkSeriesAsUnread + + [Fact] + public async Task MarkSeriesAsUnreadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + + await _context.SaveChangesAsync(); + Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + + await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await _context.SaveChangesAsync(); + + var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + Assert.Equal(0, progresses.Max(p => p.PagesRead)); + Assert.Equal(2, progresses.Count); + } + + #endregion } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 913f53133..7fc8769e7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -338,6 +338,12 @@ namespace API.Controllers + /// + /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no + /// email will be sent. + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("invite")] public async Task> InviteUser(InviteUserDto dto) @@ -417,7 +423,9 @@ namespace API.Controllers var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - if (dto.SendEmail) + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var accessible = await _emailService.CheckIfAccessible(host); + if (accessible) { await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() { @@ -426,7 +434,11 @@ namespace API.Controllers ServerConfirmationLink = emailLink }); } - return Ok(emailLink); + return Ok(new InviteUserResponse + { + EmailLink = emailLink, + EmailSent = accessible + }); } catch (Exception) { diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index a8a34bdfd..fa3be9c05 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -109,14 +109,7 @@ namespace API.Controllers public async Task MarkRead(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); - user.Progresses ??= new List(); - foreach (var volume in volumes) - { - _readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters); - } - - _unitOfWork.UserRepository.Update(user); + await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); if (await _unitOfWork.CommitAsync()) { @@ -137,14 +130,7 @@ namespace API.Controllers public async Task MarkUnread(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); - user.Progresses ??= new List(); - foreach (var volume in volumes) - { - _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters); - } - - _unitOfWork.UserRepository.Update(user); + await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); if (await _unitOfWork.CommitAsync()) { diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 04c9c1103..42d4bdf8e 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -16,6 +16,4 @@ public class InviteUserDto /// A list of libraries to grant access to /// public IList Libraries { get; init; } - - public bool SendEmail { get; init; } = true; } diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs new file mode 100644 index 000000000..9387b5492 --- /dev/null +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Account; + +public class InviteUserResponse +{ + /// + /// Email link used to setup the user account + /// + public string EmailLink { get; set; } + /// + /// Was an email sent (ie is this server accessible) + /// + public bool EmailSent { get; set; } +} diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index fba9a7493..90ad52759 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -25,51 +25,51 @@ namespace API.DTOs.Filtering /// public IList Genres { get; init; } = new List(); /// - /// A list of Writers to restrict search to. Defaults to all genres by passing an empty list + /// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list /// public IList Writers { get; init; } = new List(); /// - /// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list /// public IList Penciller { get; init; } = new List(); /// - /// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list /// public IList Inker { get; init; } = new List(); /// - /// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list /// public IList Colorist { get; init; } = new List(); /// - /// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list /// public IList Letterer { get; init; } = new List(); /// - /// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list /// public IList CoverArtist { get; init; } = new List(); /// - /// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list /// public IList Editor { get; init; } = new List(); /// - /// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list /// public IList Publisher { get; init; } = new List(); /// - /// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list /// public IList Character { get; init; } = new List(); /// - /// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list /// public IList Translators { get; init; } = new List(); /// - /// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list /// public IList CollectionTags { get; init; } = new List(); /// - /// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list + /// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list /// public IList Tags { get; init; } = new List(); /// diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 7c847d926..9f33bada7 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -12,6 +12,7 @@ namespace API.DTOs.Reader public MangaFormat SeriesFormat { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } public string ChapterTitle { get; set; } = string.Empty; public int Pages { get; set; } public string FileName { get; set; } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 330aa4b5e..ab3684fa0 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -81,7 +81,8 @@ public class ChapterRepository : IChapterRepository data.TitleName, SeriesFormat = series.Format, SeriesName = series.Name, - series.LibraryId + series.LibraryId, + LibraryType = series.Library.Type }) .Select(data => new ChapterInfoDto() { @@ -89,12 +90,13 @@ public class ChapterRepository : IChapterRepository VolumeNumber = data.VolumeNumber + string.Empty, VolumeId = data.VolumeId, IsSpecial = data.IsSpecial, - SeriesId =data.SeriesId, + SeriesId = data.SeriesId, SeriesFormat = data.SeriesFormat, SeriesName = data.SeriesName, LibraryId = data.LibraryId, Pages = data.Pages, - ChapterTitle = data.TitleName + ChapterTitle = data.TitleName, + LibraryType = data.LibraryType }) .AsNoTracking() .AsSplitQuery() diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index efe2f1a27..d70681b46 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -79,8 +79,8 @@ public interface ISeriesRepository /// Task AddSeriesModifiers(int userId, List series); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); - Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task> GetFilesForSeries(int seriesId); @@ -593,11 +593,11 @@ public class SeriesRepository : ISeriesRepository /// Pagination information /// Optional (default null) filter on query /// - public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true) { //var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync(); //var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress); - var cuttoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); + var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter)) .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new @@ -612,8 +612,12 @@ public class SeriesRepository : ISeriesRepository // This is only taking into account chapters that have progress on them, not all chapters in said series LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created) //LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created) - }) - .Where(d => d.LastReadingProgress >= cuttoffProgressPoint); + }); + if (cutoffOnDate) + { + query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint); + } + // I think I need another Join statement. The problem is the chapters are still limited to progress diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index ab2c52a2c..c5ba90464 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -98,7 +98,7 @@ public class EmailService : IEmailService return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data); } - private static async Task SendEmailWithGet(string url) + private static async Task SendEmailWithGet(string url, int timeoutSecs = 30) { try { @@ -108,7 +108,7 @@ public class EmailService : IEmailService .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .GetStringAsync(); if (!string.IsNullOrEmpty(response) && bool.Parse(response)) @@ -124,7 +124,7 @@ public class EmailService : IEmailService } - private static async Task SendEmailWithPost(string url, object data) + private static async Task SendEmailWithPost(string url, object data, int timeoutSecs = 30) { try { @@ -134,7 +134,7 @@ public class EmailService : IEmailService .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 4e73b422e..3e6fe06a5 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -16,6 +16,8 @@ namespace API.Services; public interface IReaderService { + Task MarkSeriesAsRead(AppUser user, int seriesId); + Task MarkSeriesAsUnread(AppUser user, int seriesId); void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); Task SaveReadingProgress(ProgressDto progressDto, int userId); @@ -45,6 +47,40 @@ public class ReaderService : IReaderService return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}")); } + /// + /// Does not commit. Marks all entities under the series as read. + /// + /// + /// + public async Task MarkSeriesAsRead(AppUser user, int seriesId) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId); + user.Progresses ??= new List(); + foreach (var volume in volumes) + { + MarkChaptersAsRead(user, seriesId, volume.Chapters); + } + + _unitOfWork.UserRepository.Update(user); + } + + /// + /// Does not commit. Marks all entities under the series as unread. + /// + /// + /// + public async Task MarkSeriesAsUnread(AppUser user, int seriesId) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId); + user.Progresses ??= new List(); + foreach (var volume in volumes) + { + MarkChaptersAsUnread(user, seriesId, volume.Chapters); + } + + _unitOfWork.UserRepository.Update(user); + } + /// /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit. /// @@ -364,7 +400,7 @@ public class ReaderService : IReaderService .ToList(); // If there are any volumes that have progress, return those. If not, move on. - var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); + var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); // (removed for GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress), not sure if needed && chapter.PagesRead > 0 if (currentlyReadingChapter != null) return currentlyReadingChapter; // Check loose leaf chapters (and specials). First check if there are any diff --git a/UI/Web/src/app/_models/invite-user-response.ts b/UI/Web/src/app/_models/invite-user-response.ts new file mode 100644 index 000000000..a9042c555 --- /dev/null +++ b/UI/Web/src/app/_models/invite-user-response.ts @@ -0,0 +1,10 @@ +export interface InviteUserResponse { + /** + * Link to register new user + */ + emailLink: string; + /** + * If an email was sent to the invited user + */ + emailSent: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 791c6643a..df5555f41 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -8,6 +8,7 @@ import { User } from '../_models/user'; import { Router } from '@angular/router'; import { MessageHubService } from './message-hub.service'; import { ThemeService } from '../theme.service'; +import { InviteUserResponse } from '../_models/invite-user-response'; @Injectable({ providedIn: 'root' @@ -134,8 +135,8 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); } - inviteUser(model: {email: string, roles: Array, libraries: Array, sendEmail: boolean}) { - return this.httpClient.post(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'}); + inviteUser(model: {email: string, roles: Array, libraries: Array}) { + return this.httpClient.post(this.baseUrl + 'account/invite', model); } confirmEmail(model: {email: string, username: string, password: string, token: string}) { diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 8baed6a00..d594cbff3 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -19,6 +19,7 @@ import { InviteUserComponent } from './invite-user/invite-user.component'; import { RoleSelectorComponent } from './role-selector/role-selector.component'; import { LibrarySelectorComponent } from './library-selector/library-selector.component'; import { EditUserComponent } from './edit-user/edit-user.component'; +import { UserSettingsModule } from '../user-settings/user-settings.module'; @@ -49,7 +50,8 @@ import { EditUserComponent } from './edit-user/edit-user.component'; NgbTooltipModule, NgbDropdownModule, SharedModule, - PipeModule + PipeModule, + UserSettingsModule // API-key componet ], providers: [] }) diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 83fbb86eb..495b3f6e9 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -9,13 +9,7 @@ Invite a user to your server. Enter their email in and we will send them an email to create an account.

-

- -  Checking accessibility of server... -

- - -
+
@@ -28,11 +22,6 @@
- -

Use this link to finish setting up the user account due to your server not being accessible outside your local network.

- -
-
@@ -44,12 +33,21 @@
+ +

User invited

+

You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user. + If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account. +

+ + +
+
@@ -106,24 +106,26 @@

{{utilityService.formatChapterName(libraryType) + 's'}}

  • - +
    - + - {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + + {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + - + {{chapter.pagesRead}} / {{chapter.pages}} UNREAD READ - File(s) + Files
    • diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index fc1897afe..787b5e7d3 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -67,6 +67,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { publicationStatuses: Array = []; validLanguages: Array = []; + coverImageReset = false; + get Breakpoint(): typeof Breakpoint { return Breakpoint; } @@ -403,30 +405,22 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.seriesService.updateMetadata(this.metadata, this.collectionTags) ]; - // We only need to call updateSeries if we changed name, sort name, or localized name - if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty) { + // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image + if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty || this.coverImageReset) { apis.push(this.seriesService.updateSeries(model)); } - - if (selectedIndex > 0) { + if (selectedIndex > 0 && this.selectedCover !== '') { apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover)); } + forkJoin(apis).subscribe(results => { this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0}); }); } - handleUnlock(field: string) { - console.log('todo: unlock ', field); - } - - hello(val: boolean) { - console.log('hello: ', val); - } - updateCollections(tags: CollectionTag[]) { this.collectionTags = tags; } @@ -491,6 +485,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { } handleReset() { + this.coverImageReset = true; this.editSeriesForm.patchValue({ coverImageLocked: false }); diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 6c5147d7e..f6262d111 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -27,9 +27,7 @@

      Book Settings - +

      @@ -324,7 +322,7 @@
-
+
diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index e6a43a1be..1ca53e63d 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -113,6 +113,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService, private utilityService: UtilityService, private collectionTagService: CollectionTagService) { this.filter = this.seriesService.createSeriesFilter(); + this.readProgressGroup = new FormGroup({ read: new FormControl(this.filter.readStatus.read, []), notRead: new FormControl(this.filter.readStatus.notRead, []), @@ -163,6 +164,12 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.filterSettings = new FilterSettings(); } + if (this.filterSettings.presets) { + this.readProgressGroup.get('read')?.patchValue(this.filterSettings.presets?.readStatus.read); + this.readProgressGroup.get('notRead')?.patchValue(this.filterSettings.presets?.readStatus.notRead); + this.readProgressGroup.get('inProgress')?.patchValue(this.filterSettings.presets?.readStatus.inProgress); + } + this.setupTypeaheads(); } diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index dade32f8b..610d67f1b 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -141,7 +141,11 @@ export class LibraryComponent implements OnInit, OnDestroy { } else if (sectionTitle.toLowerCase() === 'recently updated series') { this.router.navigate(['recently-added']); } else if (sectionTitle.toLowerCase() === 'on deck') { - this.router.navigate(['on-deck']); + const params: any = {}; + params['readStatus'] = 'true,false,false'; + params['page'] = 1; + this.router.navigate(['all-series'], {queryParams: params}); + //this.router.navigate(['on-deck']); } else if (sectionTitle.toLowerCase() === 'libraries') { this.router.navigate(['all-series']); } diff --git a/UI/Web/src/app/manga-reader/_models/chapter-info.ts b/UI/Web/src/app/manga-reader/_models/chapter-info.ts index f83c86902..f9313b973 100644 --- a/UI/Web/src/app/manga-reader/_models/chapter-info.ts +++ b/UI/Web/src/app/manga-reader/_models/chapter-info.ts @@ -1,3 +1,4 @@ +import { LibraryType } from "src/app/_models/library"; import { MangaFormat } from "src/app/_models/manga-format"; export interface ChapterInfo { @@ -8,6 +9,7 @@ export interface ChapterInfo { seriesFormat: MangaFormat; seriesId: number; libraryId: number; + libraryType: LibraryType; fileName: string; isSpecial: boolean; volumeId: number; diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index ae6563fd9..7891cb54a 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -38,7 +38,7 @@
- @@ -128,27 +128,19 @@
-
-
+
+
 
+
-
-
- -
-
-
-
-
+
  -
-
+ +
-
+
@@ -173,17 +168,6 @@
- -
-
- -
-
- -
-
diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.scss b/UI/Web/src/app/manga-reader/manga-reader.component.scss index 65bef4e82..0bc038212 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.scss +++ b/UI/Web/src/app/manga-reader/manga-reader.component.scss @@ -47,9 +47,9 @@ img { } } -canvas { - position: absolute; -} +// canvas { +// //position: absolute; // JOE: Not sure why we have this, but it breaks the renderer +// } .reader { background-color: var(--manga-reader-bg-color); diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 9ad50d2e8..1e3eae62f 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -122,7 +122,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Used soley for LayoutMode.Double rendering. Will always hold the next image in buffer. */ canvasImage2 = new Image(); - renderWithCanvas: boolean = false; // Dictates if we use render with canvas or with image + /** + * Dictates if we use render with canvas or with image. This is only for Splitting. + */ + renderWithCanvas: boolean = false; /** * A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation. @@ -530,11 +533,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. this.pageOptions = newOptions; - // TODO: Move this into ChapterInfo - this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => { - this.libraryType = type; - this.updateTitle(results.chapterInfo, type); - }); + this.libraryType = results.chapterInfo.libraryType; + this.updateTitle(results.chapterInfo, this.libraryType); this.inSetup = false; @@ -645,20 +645,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFittingOptionClass() { const formControl = this.generalSettingsForm.get('fittingOption'); - let val = FITTING_OPTION.HEIGHT; + let val = FITTING_OPTION.HEIGHT as string; if (formControl === undefined) { - val = FITTING_OPTION.HEIGHT; + val = FITTING_OPTION.HEIGHT as string; } val = formControl?.value; - - if (this.isCoverImage() && this.layoutMode !== LayoutMode.Single) { - return val + ' cover double'; + if (this.layoutMode !== LayoutMode.Single) { + val = val + (this.isCoverImage() ? 'cover' : '') + 'double'; + } else if (this.isCoverImage() && this.shouldRenderAsFitSplit()) { + // JOE: If we are Fit to Screen, we should use fitting as width just for cover images + // Rewriting to fit to width for this cover image + val = FITTING_OPTION.WIDTH; } - if (!this.isCoverImage() && this.layoutMode !== LayoutMode.Single) { - return val + ' double'; - } return val; } @@ -971,6 +971,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.renderWithCanvas = true; } else { this.renderWithCanvas = false; + + // if (this.isCoverImage() && this.layoutMode === LayoutMode.Single && this.getFit() !== FITTING_OPTION.WIDTH) { + + // } } // Reset scroll on non HEIGHT Fits diff --git a/UI/Web/src/app/on-deck/on-deck.component.html b/UI/Web/src/app/on-deck/on-deck.component.html deleted file mode 100644 index d03b4b696..000000000 --- a/UI/Web/src/app/on-deck/on-deck.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/UI/Web/src/app/on-deck/on-deck.component.scss b/UI/Web/src/app/on-deck/on-deck.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/UI/Web/src/app/on-deck/on-deck.component.ts b/UI/Web/src/app/on-deck/on-deck.component.ts deleted file mode 100644 index a7bcbe5f1..000000000 --- a/UI/Web/src/app/on-deck/on-deck.component.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Component, HostListener, OnInit } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { Router, ActivatedRoute } from '@angular/router'; -import { take } from 'rxjs/operators'; -import { BulkSelectionService } from '../cards/bulk-selection.service'; -import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; -import { KEY_CODES } from '../shared/_services/utility.service'; -import { Pagination } from '../_models/pagination'; -import { Series } from '../_models/series'; -import { FilterEvent, SeriesFilter} from '../_models/series-filter'; -import { Action } from '../_services/action-factory.service'; -import { ActionService } from '../_services/action.service'; -import { SeriesService } from '../_services/series.service'; - -@Component({ - selector: 'app-on-deck', - templateUrl: './on-deck.component.html', - styleUrls: ['./on-deck.component.scss'] -}) -export class OnDeckComponent implements OnInit { - - isLoading: boolean = true; - series: Series[] = []; - pagination!: Pagination; - libraryId!: number; - filter: SeriesFilter | undefined = undefined; - filterSettings: FilterSettings = new FilterSettings(); - - constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title, - private actionService: ActionService, public bulkSelectionService: BulkSelectionService) { - this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.titleService.setTitle('Kavita - On Deck'); - if (this.pagination === undefined || this.pagination === null) { - this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; - } - this.filterSettings.readProgressDisabled = true; - this.filterSettings.sortDisabled = true; - this.loadPage(); - } - - @HostListener('document:keydown.shift', ['$event']) - handleKeypress(event: KeyboardEvent) { - if (event.key === KEY_CODES.SHIFT) { - this.bulkSelectionService.isShiftDown = true; - } - } - - @HostListener('document:keyup.shift', ['$event']) - handleKeyUp(event: KeyboardEvent) { - if (event.key === KEY_CODES.SHIFT) { - this.bulkSelectionService.isShiftDown = false; - } - } - - ngOnInit() {} - - seriesClicked(series: Series) { - this.router.navigate(['library', this.libraryId, 'series', series.id]); - } - - onPageChange(pagination: Pagination) { - window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); - this.loadPage(); - } - - updateFilter(event: FilterEvent) { - this.filter = event.filter; - const page = this.getPage(); - if (page === undefined || page === null || !event.isFirst) { - this.pagination.currentPage = 1; - this.onPageChange(this.pagination); - } else { - this.loadPage(); - } - } - - loadPage() { - const page = this.getPage(); - if (page != null) { - this.pagination.currentPage = parseInt(page, 10); - } - this.isLoading = true; - this.seriesService.getOnDeck(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { - this.series = series.result; - this.pagination = series.pagination; - this.isLoading = false; - window.scrollTo(0, 0); - }); - } - - getPage() { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get('page'); - } - - bulkActionCallback = (action: Action, data: any) => { - const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); - const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); - - switch (action) { - case Action.AddToReadingList: - this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => { - this.bulkSelectionService.deselectAll(); - }); - break; - case Action.AddToCollection: - this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => { - this.bulkSelectionService.deselectAll(); - }); - break; - case Action.MarkAsRead: - this.actionService.markMultipleSeriesAsRead(selectedSeries, () => { - this.loadPage(); - this.bulkSelectionService.deselectAll(); - }); - break; - case Action.MarkAsUnread: - this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => { - this.loadPage(); - this.bulkSelectionService.deselectAll(); - }); - break; - case Action.Delete: - this.actionService.deleteMultipleSeries(selectedSeries, () => { - this.loadPage(); - this.bulkSelectionService.deselectAll(); - }); - break; - } - } - -} diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index f82caf80c..fc48ea0de 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -5,7 +5,6 @@
-

{{series?.name}}

diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 08b8d42c7..e59087f63 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -496,7 +496,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { // If user has progress on the volume, load them where they left off if (volume.pagesRead < volume.pages && volume.pagesRead > 0) { // Find the continue point chapter and load it - this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => this.openChapter(chapter)); + const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages); + if (unreadChapters.length > 0) { + this.openChapter(unreadChapters[0]); + return; + } + this.openChapter(volume.chapters[0]); return; } @@ -509,7 +514,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } openViewInfo(data: Volume | Chapter) { - const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); // , scrollable: true (these don't work well on mobile) + const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); modalRef.componentInstance.data = data; modalRef.componentInstance.parentName = this.series?.name; modalRef.componentInstance.seriesId = this.series?.id; diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 06241a544..b0d0e3410 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -207,6 +207,18 @@ export class UtilityService { filter.translators = [...filter.translators, ...translators.split(',').map(item => parseInt(item, 10))]; anyChanged = true; } + + /// Read status is encoded as true,true,true + const readStatus = snapshot.queryParamMap.get('readStatus'); + if (readStatus !== undefined && readStatus !== null) { + const values = readStatus.split(',').map(i => i === "true"); + if (values.length === 3) { + filter.readStatus.inProgress = values[0]; + filter.readStatus.notRead = values[1]; + filter.readStatus.read = values[2]; + anyChanged = true; + } + } return [filter, anyChanged]; diff --git a/UI/Web/src/app/user-settings/api-key/api-key.component.html b/UI/Web/src/app/user-settings/api-key/api-key.component.html index 588181ef9..6eb7b3363 100644 --- a/UI/Web/src/app/user-settings/api-key/api-key.component.html +++ b/UI/Web/src/app/user-settings/api-key/api-key.component.html @@ -4,7 +4,7 @@
- +
diff --git a/UI/Web/src/app/user-settings/user-settings.module.ts b/UI/Web/src/app/user-settings/user-settings.module.ts index d7ce36f44..430d68dd4 100644 --- a/UI/Web/src/app/user-settings/user-settings.module.ts +++ b/UI/Web/src/app/user-settings/user-settings.module.ts @@ -31,12 +31,12 @@ import { ColorPickerModule } from 'ngx-color-picker'; NgbTooltipModule, NgxSliderModule, UserSettingsRoutingModule, - //SharedModule, // SentenceCase pipe PipeModule, ColorPickerModule, // User prefernces background color ], exports: [ - SiteThemeProviderPipe + SiteThemeProviderPipe, + ApiKeyComponent ] }) export class UserSettingsModule { }