mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Shakeout Testing Part 2 (#1053)
* Fixed an issue where cover update events wouldn't refresh an image after the second event came through due to randomization * Fixed an issue where download event wasn't send consistently when downloading files. * Fixed a bug where you couldn't add a new role to a user * Fixed a bug where if you went to edit user library, the roles would get reset to nothing * Adjust the rendering on reading list page to be better on smaller screens (and worse on larger ones) * Tweaked the refresh covers message to use queued and not started * Cleaned up the code for image on when to update based on CoverUpdate event. On dashboard, don't spam reload recently added on every series update or scan complete. Debouce for 1 second between calls. * Fixed an issue where we sent an error on forgot password confirmation, but really, it was successful. * Added Reading Lists and Library to search results * Fixed a bug in the search component where hitting esc to close overlay then typing again wouldn't reopen the overlay * When resending invites, send the correct link for an invite * Tell the admin an email was sent on invite * Fixed the error interceptor to flatten validation error messages more robustly and now confirm email will show validation exceptions * Fixed a bug in webtoon reader where we were reading the wrong dimension for fitting images to screen on render * When generating email links, inform who they are for in the logs. Fixed an issue with an error message on login when password was incorrect, but user hadn't confirmed email yet. Fixed multiple cases where migration wasn't sending error messages back correctly and hence the user never saw them. * Show errors on migration UI form * Changed log rolling to be easier to understand * Added some extra logic to throw unauthorized * Tweaked some wording to inform user how to best find email link * Fixed a code smell
This commit is contained in:
parent
d18cfbc44f
commit
4fcab5800e
@ -164,6 +164,11 @@ namespace API.Controllers
|
||||
"You are missing an email on your account. Please wait while we migrate your account.");
|
||||
}
|
||||
|
||||
if (!validPassword)
|
||||
{
|
||||
return Unauthorized("Your credentials are not correct");
|
||||
}
|
||||
|
||||
var result = await _signInManager
|
||||
.CheckPasswordSignInAsync(user, loginDto.Password, false);
|
||||
|
||||
@ -280,7 +285,7 @@ namespace API.Controllers
|
||||
{
|
||||
dto.Roles.Add(PolicyConstants.PlebRole);
|
||||
}
|
||||
if (existingRoles.Except(dto.Roles).Any())
|
||||
if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any())
|
||||
{
|
||||
var roles = dto.Roles;
|
||||
|
||||
@ -334,6 +339,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
||||
{
|
||||
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (adminUser == null) return Unauthorized("You need to login");
|
||||
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
|
||||
|
||||
// Check if there is an existing invite
|
||||
@ -402,10 +408,8 @@ namespace API.Controllers
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
|
||||
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
var emailLink =
|
||||
$"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(dto.Email)}";
|
||||
_logger.LogInformation("[Invite User]: Email Link: {Link}", emailLink);
|
||||
var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email);
|
||||
_logger.LogInformation("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
if (dto.SendEmail)
|
||||
{
|
||||
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
@ -479,10 +483,10 @@ namespace API.Controllers
|
||||
}
|
||||
|
||||
var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", dto.Token);
|
||||
if (!result) return BadRequest("Unable to reset password");
|
||||
if (!result) return BadRequest("Unable to reset password, your email token is not correct.");
|
||||
|
||||
var errors = await _accountService.ChangeUserPassword(user, dto.Password);
|
||||
return errors.Any() ? BadRequest(errors) : BadRequest("Unable to reset password");
|
||||
return errors.Any() ? BadRequest(errors) : Ok("Password updated");
|
||||
}
|
||||
|
||||
|
||||
@ -503,7 +507,7 @@ namespace API.Controllers
|
||||
}
|
||||
|
||||
var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email);
|
||||
_logger.LogInformation("[Forgot Password]: Email Link: {Link}", emailLink);
|
||||
_logger.LogInformation("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
if (await _emailService.CheckIfAccessible(host))
|
||||
{
|
||||
@ -524,7 +528,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
|
||||
if (user == null) return Unauthorized("This email is not on system");
|
||||
if (user == null) return BadRequest("This email is not on system");
|
||||
|
||||
if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token");
|
||||
|
||||
@ -556,7 +560,7 @@ namespace API.Controllers
|
||||
"This user needs to migrate. Have them log out and login to trigger a migration flow");
|
||||
if (user.EmailConfirmed) return BadRequest("User already confirmed");
|
||||
|
||||
var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
|
||||
var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-email", user.Email);
|
||||
_logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink);
|
||||
await _emailService.SendMigrationEmail(new EmailMigrationDto()
|
||||
{
|
||||
@ -594,6 +598,8 @@ namespace API.Controllers
|
||||
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
|
||||
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
|
||||
return BadRequest($"User is already registered as {invitedUser.UserName}");
|
||||
|
||||
_logger.LogInformation("A user is attempting to login, but hasn't accepted email invite");
|
||||
return BadRequest("User is already invited under this email and has yet to accepted invite.");
|
||||
}
|
||||
|
||||
@ -601,10 +607,10 @@ namespace API.Controllers
|
||||
var user = await _userManager.Users
|
||||
.Include(u => u.UserPreferences)
|
||||
.SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper());
|
||||
if (user == null) return Unauthorized("Invalid username");
|
||||
if (user == null) return BadRequest("Invalid username");
|
||||
|
||||
var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password);
|
||||
if (!validPassword) return Unauthorized("Your credentials are not correct");
|
||||
if (!validPassword) return BadRequest("Your credentials are not correct");
|
||||
|
||||
try
|
||||
{
|
||||
@ -615,16 +621,14 @@ namespace API.Controllers
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
|
||||
_logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink);
|
||||
if (dto.SendEmail)
|
||||
_logger.LogInformation("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink);
|
||||
// Always send an email, even if the user can't click it just to get them conformable with the system
|
||||
await _emailService.SendMigrationEmail(new EmailMigrationDto()
|
||||
{
|
||||
await _emailService.SendMigrationEmail(new EmailMigrationDto()
|
||||
{
|
||||
EmailAddress = dto.Email,
|
||||
Username = user.UserName,
|
||||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
}
|
||||
EmailAddress = dto.Email,
|
||||
Username = user.UserName,
|
||||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
return Ok(emailLink);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
{
|
||||
@ -28,10 +29,11 @@ namespace API.Controllers
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILogger<DownloadController> _logger;
|
||||
private const string DefaultContentType = "application/octet-stream";
|
||||
|
||||
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService,
|
||||
IDownloadService downloadService, IHubContext<MessageHub> messageHub, UserManager<AppUser> userManager)
|
||||
IDownloadService downloadService, IHubContext<MessageHub> messageHub, UserManager<AppUser> userManager, ILogger<DownloadController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_archiveService = archiveService;
|
||||
@ -39,6 +41,7 @@ namespace API.Controllers
|
||||
_downloadService = downloadService;
|
||||
_messageHub = messageHub;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("volume-size")]
|
||||
@ -114,17 +117,34 @@ namespace API.Controllers
|
||||
|
||||
private async Task<ActionResult> DownloadFiles(ICollection<MangaFile> files, string tempFolder, string downloadName)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 0F));
|
||||
if (files.Count == 1)
|
||||
try
|
||||
{
|
||||
return await GetFirstFileDownload(files);
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 0F));
|
||||
if (files.Count == 1)
|
||||
{
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
return await GetFirstFileDownload(files);
|
||||
}
|
||||
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||
tempFolder);
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
return File(fileBytes, DefaultContentType, downloadName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when trying to download files");
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||
Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
throw;
|
||||
}
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||
tempFolder);
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F));
|
||||
return File(fileBytes, DefaultContentType, downloadName);
|
||||
}
|
||||
|
||||
[HttpGet("series")]
|
||||
@ -160,9 +180,14 @@ namespace API.Controllers
|
||||
.ToList()))
|
||||
.Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, $"{b.ChapterId}_{b.FileName}")));
|
||||
|
||||
var filename = $"{series.Name} - Bookmarks.zip";
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F));
|
||||
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files,
|
||||
$"download_{user.Id}_{series.Id}_bookmarks");
|
||||
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
|
||||
await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
|
||||
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F));
|
||||
return File(fileBytes, DefaultContentType, filename);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ namespace API.DTOs.Search;
|
||||
/// </summary>
|
||||
public class SearchResultGroupDto
|
||||
{
|
||||
public IEnumerable<LibraryDto> Libraries { get; set; }
|
||||
public IEnumerable<SearchResultDto> Series { get; set; }
|
||||
public IEnumerable<CollectionTagDto> Collections { get; set; }
|
||||
public IEnumerable<ReadingListDto> ReadingLists { get; set; }
|
||||
|
@ -281,6 +281,14 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
result.Libraries = await _context.Library
|
||||
.Where(l => libraryIds.Contains(l.Id))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%"))
|
||||
.OrderBy(l => l.Name)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Series = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|
||||
|
@ -250,6 +250,7 @@ public class ScannerService : IScannerService
|
||||
|
||||
var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService);
|
||||
var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime);
|
||||
_logger.LogInformation("[ScannerService] Finished file scan. Updating database");
|
||||
|
||||
foreach (var folderPath in library.Folders)
|
||||
{
|
||||
|
@ -15,7 +15,7 @@
|
||||
"Path": "config//logs/kavita.log",
|
||||
"Append": "True",
|
||||
"FileSizeLimitBytes": 26214400,
|
||||
"MaxRollingFiles": 2
|
||||
"MaxRollingFiles": 1
|
||||
}
|
||||
},
|
||||
"Port": 5000
|
||||
|
@ -62,9 +62,15 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
if (Array.isArray(error.error)) {
|
||||
const modalStateErrors: any[] = [];
|
||||
if (error.error.length > 0 && error.error[0].hasOwnProperty('message')) {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.details);
|
||||
});
|
||||
if (error.error[0].details === null) {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.message);
|
||||
});
|
||||
} else {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.details);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
error.error.forEach((issue: {code: string, description: string}) => {
|
||||
modalStateErrors.push(issue.description);
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Library } from "../library";
|
||||
import { SearchResult } from "../search-result";
|
||||
import { Tag } from "../tag";
|
||||
|
||||
export class SearchResultGroup {
|
||||
libraries: Array<Library> = [];
|
||||
series: Array<SearchResult> = [];
|
||||
collections: Array<Tag> = [];
|
||||
readingLists: Array<Tag> = [];
|
||||
@ -10,6 +12,7 @@ export class SearchResultGroup {
|
||||
tags: Array<Tag> = [];
|
||||
|
||||
reset() {
|
||||
this.libraries = [];
|
||||
this.series = [];
|
||||
this.collections = [];
|
||||
this.readingLists = [];
|
||||
|
@ -149,7 +149,7 @@ export class ActionService implements OnDestroy {
|
||||
}
|
||||
|
||||
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Refresh started for ' + series.name);
|
||||
this.toastr.success('Refresh covers queued for ' + series.name);
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -49,6 +49,18 @@ export class ImageService implements OnDestroy {
|
||||
return this.getChapterCoverImage(item.chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity type from a cover image url. Undefied if not applicable
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
getEntityTypeFromUrl(url: string) {
|
||||
if (url.indexOf('?') < 0) return undefined;
|
||||
const part = url.split('?')[1];
|
||||
const equalIndex = part.indexOf('=');
|
||||
return part.substring(0, equalIndex).replace('Id', '');
|
||||
}
|
||||
|
||||
getVolumeCoverImage(volumeId: number) {
|
||||
return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
@ -30,7 +31,7 @@ export class InviteUserComponent implements OnInit {
|
||||
public get email() { return this.inviteForm.get('email'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
|
||||
private confirmService: ConfirmService) { }
|
||||
private confirmService: ConfirmService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
|
||||
@ -57,10 +58,11 @@ export class InviteUserComponent implements OnInit {
|
||||
libraries: this.selectedLibraries,
|
||||
roles: this.selectedRoles,
|
||||
sendEmail: this.accessible
|
||||
}).subscribe(email => {
|
||||
this.emailLink = email;
|
||||
}).subscribe(emailLink => {
|
||||
this.emailLink = emailLink;
|
||||
this.isSending = false;
|
||||
if (this.accessible) {
|
||||
this.toastr.info('Email sent to ' + email);
|
||||
this.modal.close(true);
|
||||
}
|
||||
}, err => {
|
||||
|
@ -34,8 +34,8 @@ export class RoleSelectorComponent implements OnInit {
|
||||
this.selectedRoles = roles.map(item => {
|
||||
return {selected: false, data: item};
|
||||
});
|
||||
this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data));
|
||||
this.preselect();
|
||||
this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -35,6 +35,26 @@
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="readingListTemplate !== undefined && grouppedData.readingLists.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Reading Lists</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.readingLists; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="libraryTemplate !== undefined && grouppedData.libraries.length > 0">
|
||||
<li class="list-group-item section-header"><h5 id="libraries-group">Libraries</h5></li>
|
||||
<ul class="list-group results" role="group" aria-describedby="libraries-group">
|
||||
<li *ngFor="let option of grouppedData.libraries; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" aria-labelledby="libraries-group" role="option">
|
||||
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="genreTemplate !== undefined && grouppedData.genres.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Genres</h5></li>
|
||||
<ul class="list-group results">
|
||||
|
@ -58,6 +58,8 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
|
||||
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
|
||||
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
|
||||
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
|
||||
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
|
||||
|
||||
|
||||
hasFocus: boolean = false;
|
||||
@ -103,6 +105,11 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntil(this.onDestroy)).subscribe(change => {
|
||||
const value = this.typeaheadForm.get('typeahead')?.value;
|
||||
|
||||
if (value != undefined && value != '' && !this.hasFocus) {
|
||||
this.hasFocus = true;
|
||||
}
|
||||
|
||||
if (value != undefined && value.length >= this.minQueryLength) {
|
||||
|
||||
if (this.prevSearchTerm === value) return;
|
||||
|
@ -11,6 +11,7 @@
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<!-- TODO: Refactor this so we can use series actions here -->
|
||||
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||
@ -36,7 +36,10 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`;
|
||||
/**
|
||||
* We use this Replay subject to slow the amount of times we reload the UI
|
||||
*/
|
||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
private seriesService: SeriesService, private router: Router,
|
||||
@ -49,7 +52,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||
this.recentlyAddedSeries.unshift(series);
|
||||
});
|
||||
this.loadRecentlyAdded();
|
||||
} else if (res.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
|
||||
|
||||
@ -59,9 +61,11 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
||||
} else if (res.event === EVENTS.ScanSeries) {
|
||||
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
|
||||
this.loadRecentlyAdded();
|
||||
this.loadRecentlyAdded$.next();
|
||||
}
|
||||
});
|
||||
|
||||
this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntil(this.onDestroy)).subscribe(() => this.loadRecentlyAdded());
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -143,7 +143,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
get areImagesWiderThanWindow() {
|
||||
let [innerWidth, _] = this.getInnerDimensions();
|
||||
let [_, innerWidth] = this.getInnerDimensions();
|
||||
return this.webtoonImageWidth > (innerWidth || document.documentElement.clientWidth);
|
||||
}
|
||||
|
||||
@ -186,6 +186,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.initScrollHandler();
|
||||
|
||||
this.recalculateImageWidth();
|
||||
|
||||
if (this.goToPage) {
|
||||
this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
|
||||
const isSamePage = this.pageNum === page;
|
||||
@ -218,14 +220,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.fullscreenToggled.pipe(takeUntil(this.onDestroy)).subscribe(isFullscreen => {
|
||||
this.debugLog('[FullScreen] Fullscreen mode: ', isFullscreen);
|
||||
this.isFullscreenMode = isFullscreen;
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.recalculateImageWidth();
|
||||
this.initScrollHandler();
|
||||
this.setPageNum(this.pageNum, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
recalculateImageWidth() {
|
||||
const [_, innerWidth] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
}
|
||||
|
||||
getVerticalOffset() {
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window;
|
||||
|
||||
@ -338,6 +344,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns Height, Width
|
||||
*/
|
||||
getInnerDimensions() {
|
||||
let innerHeight = window.innerHeight;
|
||||
let innerWidth = window.innerWidth;
|
||||
@ -399,8 +409,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
initWebtoonReader() {
|
||||
this.initFinished = false;
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.recalculateImageWidth();
|
||||
this.imagesLoaded = {};
|
||||
this.webtoonImages.next([]);
|
||||
this.atBottom = false;
|
||||
|
@ -72,8 +72,6 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
||||
|
||||
processProgressEvent(event: Message<ProgressEvent>, eventType: string) {
|
||||
const scanEvent = event.payload as ProgressEvent;
|
||||
console.log(event.event, event.payload);
|
||||
|
||||
|
||||
this.libraryService.getLibraryNames().subscribe(names => {
|
||||
const data = this.progressEventsSource.getValue();
|
||||
|
@ -22,6 +22,14 @@
|
||||
(focusChanged)="focusUpdate($event)"
|
||||
>
|
||||
|
||||
<ng-template #libraryTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
|
||||
<div class="ml-1">
|
||||
<span>{{item.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #seriesTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
|
||||
<div style="width: 24px" class="mr-1">
|
||||
@ -53,6 +61,18 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #readingListTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
|
||||
<div class="ml-1">
|
||||
<span>{{item.title}}</span>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #tagTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
|
||||
<div class="ml-1">
|
||||
|
@ -5,7 +5,9 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ScrollService } from '../scroll.service';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Library } from '../_models/library';
|
||||
import { PersonRole } from '../_models/person';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
import { SearchResult } from '../_models/search-result';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
@ -164,11 +166,20 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['library', libraryId, 'series', seriesId]);
|
||||
}
|
||||
|
||||
clickLibraryResult(item: Library) {
|
||||
this.router.navigate(['library', item.id]);
|
||||
}
|
||||
|
||||
clickCollectionSearchResult(item: CollectionTag) {
|
||||
this.clearSearch();
|
||||
this.router.navigate(['collections', item.id]);
|
||||
}
|
||||
|
||||
clickReadingListSearchResult(item: ReadingList) {
|
||||
this.clearSearch();
|
||||
this.router.navigate(['lists', item.id]);
|
||||
}
|
||||
|
||||
|
||||
scrollToTop() {
|
||||
window.scroll({
|
||||
|
@ -1,4 +1,4 @@
|
||||
<div class="container-sm mt-2" *ngIf="readingList">
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
<div class="mb-3">
|
||||
<!-- Title row-->
|
||||
<div class="row no-gutters">
|
||||
|
@ -8,6 +8,9 @@
|
||||
<p>Your account does not have an email on file. This is a one-time migration. Please add your email to the account. A verficiation link will be sent to your email for you
|
||||
to confirm and will then be allowed to authenticate with this server. This is required.
|
||||
</p>
|
||||
|
||||
<p class="text-danger" *ngIf="error.length > 0">{{error}}</p>
|
||||
|
||||
<form [formGroup]="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
|
@ -23,6 +23,7 @@ export class AddEmailToAccountMigrationModalComponent implements OnInit {
|
||||
registerForm: FormGroup = new FormGroup({});
|
||||
emailLink: string = '';
|
||||
emailLinkUrl: SafeUrl | undefined;
|
||||
error: string = '';
|
||||
|
||||
constructor(private accountService: AccountService, private modal: NgbActiveModal,
|
||||
private serverService: ServerService, private confirmService: ConfirmService) {
|
||||
@ -43,15 +44,18 @@ export class AddEmailToAccountMigrationModalComponent implements OnInit {
|
||||
const model = this.registerForm.getRawValue();
|
||||
model.sendEmail = canAccess;
|
||||
this.accountService.migrateUser(model).subscribe(async (email) => {
|
||||
console.log(email);
|
||||
if (!canAccess) {
|
||||
// Display the email to the user
|
||||
this.emailLink = email;
|
||||
await this.confirmService.alert('Please click this link to confirm your email. You must confirm to be able to login. The link is in your logs. You may need to log out of the current account before clicking. <br/> <a href="' + this.emailLink + '" target="_blank">' + this.emailLink + '</a>');
|
||||
this.modal.close(true);
|
||||
} else {
|
||||
await this.confirmService.alert('Please check your email (or logs) for the confirmation link. You must confirm to be able to login.');
|
||||
await this.confirmService.alert('Please check your email (or logs under "Email Link") for the confirmation link. You must confirm to be able to login.');
|
||||
this.modal.close(true);
|
||||
}
|
||||
}, err => {
|
||||
this.error = err;
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
<!--
|
||||
<div class="text-danger" *ngIf="errors.length > 0">
|
||||
<p>Errors:</p>
|
||||
<ul>
|
||||
<li *ngFor="let error of errors">{{error}}</li>
|
||||
</ul>
|
||||
</div> -->
|
||||
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>Register</h2></ng-container>
|
||||
<ng-container body>
|
||||
<p>Complete the form to complete your registration</p>
|
||||
<div class="text-danger" *ngIf="errors.length > 0">
|
||||
<p>Errors:</p>
|
||||
<ul>
|
||||
<li *ngFor="let error of errors">{{error}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
|
@ -53,6 +53,7 @@ export class ConfirmEmailComponent implements OnInit {
|
||||
this.toastr.success('Account registration complete');
|
||||
this.router.navigateByUrl('login');
|
||||
}, err => {
|
||||
console.log('error: ', err);
|
||||
this.errors = err;
|
||||
});
|
||||
}
|
||||
|
@ -49,10 +49,16 @@ export class ImageComponent implements OnChanges, OnDestroy {
|
||||
if (res.event === EVENTS.CoverUpdate) {
|
||||
const updateEvent = res.payload as CoverUpdateEvent;
|
||||
if (this.imageUrl === undefined || this.imageUrl === null || this.imageUrl === '') return;
|
||||
const enityType = this.imageService.getEntityTypeFromUrl(this.imageUrl);
|
||||
if (enityType === updateEvent.entityType) {
|
||||
const tokens = this.imageUrl.split('?')[1].split('&random=');
|
||||
|
||||
const tokens = this.imageUrl.split(updateEvent.entityType + 'Id=');
|
||||
if (tokens.length > 1 && tokens[1] === (updateEvent.id + '')) {
|
||||
this.imageUrl = this.imageService.randomize(this.imageUrl);
|
||||
//...seriesId=123&random=
|
||||
const id = tokens[0].replace(enityType + 'Id=', '');
|
||||
if (id === (updateEvent.id + '')) {
|
||||
console.log('Image url: ', this.imageUrl, ' matches update event: ', updateEvent);
|
||||
this.imageUrl = this.imageService.randomize(this.imageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user