mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Jump Bar Testing (#1302)
* Implemented a basic jump bar for the library view. This currently just interacts with existing pagination controls and is not inlined with infinite scroll yet. This is a first pass implementation. * Refactored time estimates into the reading service. * Cleaned up when the jump bar is shown to mimic pagination controls * Cleanup up code in reader service. * Scroll to card when selecting a jump key that is shown on the current page. * Ensure estimated times always has the smaller number on left hand side. * Fixed a bug with a missing vertical rule * Fixed an off by 1 pixel for search overlay
This commit is contained in:
parent
64c0b5a71e
commit
742cfd3293
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.JumpBar;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -106,6 +107,16 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosAsync());
|
||||
}
|
||||
|
||||
[HttpGet("jump-bar")]
|
||||
public async Task<ActionResult<IEnumerable<JumpKeyDto>>> GetJumpBar(int libraryId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library");
|
||||
|
||||
return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId));
|
||||
}
|
||||
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("grant-access")]
|
||||
public async Task<ActionResult<MemberDto>> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto)
|
||||
|
@ -639,25 +639,7 @@ namespace API.Controllers
|
||||
[HttpGet("manual-read-time")]
|
||||
public ActionResult<HourEstimateRangeDto> GetManualReadTime(int wordCount, int pageCount, bool isEpub)
|
||||
{
|
||||
|
||||
if (isEpub)
|
||||
{
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((wordCount / ReaderService.MinWordsPerHour)),
|
||||
MaxHours = (int) Math.Round((wordCount / ReaderService.MaxWordsPerHour)),
|
||||
AvgHours = (int) Math.Round((wordCount / ReaderService.AvgWordsPerHour)),
|
||||
HasProgress = false
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((pageCount / ReaderService.MinPagesPerMinute / 60F)),
|
||||
MaxHours = (int) Math.Round((pageCount / ReaderService.MaxPagesPerMinute / 60F)),
|
||||
AvgHours = (int) Math.Round((pageCount / ReaderService.AvgPagesPerMinute / 60F)),
|
||||
HasProgress = false
|
||||
});
|
||||
return Ok(_readerService.GetTimeEstimate(wordCount, pageCount, isEpub));
|
||||
}
|
||||
|
||||
[HttpGet("read-time")]
|
||||
@ -667,24 +649,8 @@ namespace API.Controllers
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
|
||||
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
|
||||
if (series.Format == MangaFormat.Epub)
|
||||
{
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((series.WordCount / ReaderService.MinWordsPerHour)),
|
||||
MaxHours = (int) Math.Round((series.WordCount / ReaderService.MaxWordsPerHour)),
|
||||
AvgHours = (int) Math.Round((series.WordCount / ReaderService.AvgWordsPerHour)),
|
||||
HasProgress = progress.Any()
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((series.Pages / ReaderService.MinPagesPerMinute / 60F)),
|
||||
MaxHours = (int) Math.Round((series.Pages / ReaderService.MaxPagesPerMinute / 60F)),
|
||||
AvgHours = (int) Math.Round((series.Pages / ReaderService.AvgPagesPerMinute / 60F)),
|
||||
HasProgress = progress.Any()
|
||||
});
|
||||
return Ok(_readerService.GetTimeEstimate(series.WordCount, series.Pages, series.Format == MangaFormat.Epub,
|
||||
progress.Any()));
|
||||
}
|
||||
|
||||
|
||||
@ -709,24 +675,12 @@ namespace API.Controllers
|
||||
// Word count
|
||||
var progressCount = chapters.Sum(c => c.WordCount);
|
||||
var wordsLeft = series.WordCount - progressCount;
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((wordsLeft / ReaderService.MinWordsPerHour)),
|
||||
MaxHours = (int) Math.Round((wordsLeft / ReaderService.MaxWordsPerHour)),
|
||||
AvgHours = (int) Math.Round((wordsLeft / ReaderService.AvgWordsPerHour)),
|
||||
HasProgress = progressCount > 0
|
||||
});
|
||||
return _readerService.GetTimeEstimate(wordsLeft, 0, true, progressCount > 0);
|
||||
}
|
||||
|
||||
var progressPageCount = progress.Sum(p => p.PagesRead);
|
||||
var pagesLeft = series.Pages - progressPageCount;
|
||||
return Ok(new HourEstimateRangeDto()
|
||||
{
|
||||
MinHours = (int) Math.Round((pagesLeft / ReaderService.MinPagesPerMinute / 60F)),
|
||||
MaxHours = (int) Math.Round((pagesLeft / ReaderService.MaxPagesPerMinute / 60F)),
|
||||
AvgHours = (int) Math.Round((pagesLeft / ReaderService.AvgPagesPerMinute / 60F)),
|
||||
HasProgress = progressPageCount > 0
|
||||
});
|
||||
return _readerService.GetTimeEstimate(0, pagesLeft, false, progressPageCount > 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
20
API/DTOs/JumpBar/JumpKeyDto.cs
Normal file
20
API/DTOs/JumpBar/JumpKeyDto.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace API.DTOs.JumpBar;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an individual button in a Jump Bar
|
||||
/// </summary>
|
||||
public class JumpKeyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of items in this Key
|
||||
/// </summary>
|
||||
public int Size { get; set; }
|
||||
/// <summary>
|
||||
/// Code to use in URL (url encoded)
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
/// <summary>
|
||||
/// What is visible to user
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
|
||||
/// <summary>
|
||||
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
|
||||
|
@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.JumpBar;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using AutoMapper;
|
||||
@ -38,6 +40,7 @@ public interface ILibraryRepository
|
||||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
|
||||
Task<int> GetTotalFiles();
|
||||
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
@ -123,6 +126,37 @@ public class LibraryRepository : ILibraryRepository
|
||||
return await _context.MangaFile.CountAsync();
|
||||
}
|
||||
|
||||
public IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId)
|
||||
{
|
||||
var seriesSortCharacters = _context.Series.Where(s => s.LibraryId == libraryId)
|
||||
.Select(s => s.SortName.ToUpper())
|
||||
.OrderBy(s => s)
|
||||
.AsEnumerable()
|
||||
.Select(s => s[0]);
|
||||
|
||||
// Map the title to the number of entities
|
||||
var firstCharacterMap = new Dictionary<char, int>();
|
||||
foreach (var sortChar in seriesSortCharacters)
|
||||
{
|
||||
var c = sortChar;
|
||||
var isAlpha = char.IsLetter(sortChar);
|
||||
if (!isAlpha) c = '#';
|
||||
if (!firstCharacterMap.ContainsKey(c))
|
||||
{
|
||||
firstCharacterMap[c] = 0;
|
||||
}
|
||||
|
||||
firstCharacterMap[c] += 1;
|
||||
}
|
||||
|
||||
return firstCharacterMap.Keys.Select(k => new JumpKeyDto()
|
||||
{
|
||||
Key = k + string.Empty,
|
||||
Size = firstCharacterMap[k],
|
||||
Title = k + string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
|
@ -56,6 +56,7 @@ public interface IUserRepository
|
||||
Task<IEnumerable<AppUser>> GetAllUsers();
|
||||
|
||||
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
||||
Task<bool> HasAccessToLibrary(int libraryId, int userId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -238,6 +239,13 @@ public class UserRepository : IUserRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.AnyAsync(library => library.AppUsers.Any(user => user.Id == userId));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
@ -7,6 +7,7 @@ using API.Comparators;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.SignalR;
|
||||
@ -28,6 +29,7 @@ public interface IReaderService
|
||||
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
|
||||
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
|
||||
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
|
||||
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false);
|
||||
}
|
||||
|
||||
public class ReaderService : IReaderService
|
||||
@ -168,7 +170,7 @@ public class ReaderService : IReaderService
|
||||
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
|
||||
if (progresses.Count > 1)
|
||||
{
|
||||
user.Progresses = new List<AppUserProgress>()
|
||||
user.Progresses = new List<AppUserProgress>
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
@ -478,7 +480,7 @@ public class ReaderService : IReaderService
|
||||
/// <param name="chapterNumber"></param>
|
||||
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
||||
foreach (var volume in volumes.OrderBy(v => v.Number))
|
||||
{
|
||||
var chapters = volume.Chapters
|
||||
@ -490,10 +492,57 @@ public class ReaderService : IReaderService
|
||||
|
||||
public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
||||
foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber && v.Number > 0))
|
||||
{
|
||||
MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
|
||||
}
|
||||
}
|
||||
|
||||
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false)
|
||||
{
|
||||
if (isEpub)
|
||||
{
|
||||
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 1);
|
||||
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 1);
|
||||
if (maxHours < minHours)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHours,
|
||||
MaxHours = minHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
|
||||
HasProgress = hasProgress
|
||||
};
|
||||
}
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = minHours,
|
||||
MaxHours = maxHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
|
||||
HasProgress = hasProgress
|
||||
};
|
||||
}
|
||||
|
||||
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 1);
|
||||
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 1);
|
||||
if (maxHoursPages < minHoursPages)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHoursPages,
|
||||
MaxHours = minHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
|
||||
HasProgress = hasProgress
|
||||
};
|
||||
}
|
||||
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = minHoursPages,
|
||||
MaxHours = maxHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
|
||||
HasProgress = hasProgress
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -8,9 +8,9 @@ using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -98,7 +98,7 @@ public class SeriesService : ISeriesService
|
||||
series.Metadata.SummaryLocked = true;
|
||||
}
|
||||
|
||||
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata.Language)
|
||||
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language)
|
||||
{
|
||||
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language;
|
||||
series.Metadata.LanguageLocked = true;
|
||||
@ -112,7 +112,7 @@ public class SeriesService : ISeriesService
|
||||
});
|
||||
|
||||
series.Metadata.Genres ??= new List<Genre>();
|
||||
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata.Genres, series, allGenres, (genre) =>
|
||||
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, (genre) =>
|
||||
{
|
||||
series.Metadata.Genres.Add(genre);
|
||||
}, () => series.Metadata.GenresLocked = true);
|
||||
@ -521,11 +521,11 @@ public class SeriesService : ISeriesService
|
||||
/// <summary>
|
||||
/// Should we show the given chapter on the UI. We only show non-specials and non-zero chapters.
|
||||
/// </summary>
|
||||
/// <param name="c"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
private static bool ShouldIncludeChapter(ChapterDto c)
|
||||
private static bool ShouldIncludeChapter(ChapterDto chapter)
|
||||
{
|
||||
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
return !chapter.IsSpecial && !chapter.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
}
|
||||
|
||||
public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType)
|
||||
|
8
UI/Web/package-lock.json
generated
8
UI/Web/package-lock.json
generated
@ -9543,6 +9543,14 @@
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"ngx-infinite-scroll": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-13.0.2.tgz",
|
||||
"integrity": "sha512-RSezL0DUxo1B57SyRMOSt3a/5lLXJs6P8lavtxOh10uhX+hn662cMYHUO7LiU2a/vJxef2R020s4jkUqhnXTcg==",
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"ngx-toastr": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-14.2.1.tgz",
|
||||
|
@ -39,6 +39,7 @@
|
||||
"ng-circle-progress": "^1.6.0",
|
||||
"ngx-color-picker": "^12.0.0",
|
||||
"ngx-file-drop": "^13.0.0",
|
||||
"ngx-infinite-scroll": "^13.0.2",
|
||||
"ngx-toastr": "^14.2.1",
|
||||
"requires": "^1.0.2",
|
||||
"rxjs": "~7.5.4",
|
||||
|
5
UI/Web/src/app/_models/jumpbar/jump-key.ts
Normal file
5
UI/Web/src/app/_models/jumpbar/jump-key.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface JumpKey {
|
||||
size: number;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
import { Library, LibraryType } from '../_models/library';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
|
||||
@ -58,6 +59,10 @@ export class LibraryService {
|
||||
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
|
||||
}
|
||||
|
||||
getJumpBar(libraryId: number) {
|
||||
return this.httpClient.get<JumpKey[]>(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId);
|
||||
}
|
||||
|
||||
getLibraries() {
|
||||
return this.httpClient.get<Library[]>(this.baseUrl + 'library');
|
||||
}
|
||||
|
@ -17,62 +17,79 @@
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
|
||||
<div>
|
||||
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||
|
||||
<ng-template #cardTemplate>
|
||||
<div class="card-container row g-0 mt-3 mb-3">
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
|
||||
<div class="card-container row g-0 mt-3 mb-3" >
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[maxSize]="8"
|
||||
[rotate]="true"
|
||||
[ellipses]="false"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[collectionSize]="pagination.totalItems">
|
||||
<ng-container *ngIf="pagination && items.length > 0 && id == 'bottom' && pagination.totalPages > 1 ">
|
||||
<div class="jump-bar d-flex justify-content-center">
|
||||
<button *ngFor="let jumpKey of jumpBarKeys" class="btn btn-link" (click)="scrollTo(jumpKey)">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[maxSize]="8"
|
||||
[rotate]="true"
|
||||
[ellipses]="false"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[collectionSize]="pagination.totalItems">
|
||||
|
||||
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
||||
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
||||
<div class="d-flex flex-nowrap px-2">
|
||||
<label
|
||||
id="paginationInputLabel-{{id}}"
|
||||
for="paginationInput-{{id}}"
|
||||
class="col-form-label me-2 ms-1 form-label"
|
||||
>Page</label>
|
||||
<input #i
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="form-control custom-pages-input"
|
||||
id="paginationInput-{{id}}"
|
||||
[value]="page"
|
||||
(keyup.enter)="selectPageStr(i.value)"
|
||||
(blur)="selectPageStr(i.value)"
|
||||
(input)="formatInput($any($event).target)"
|
||||
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
||||
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
||||
/>
|
||||
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
||||
of {{pagination.totalPages}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
||||
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
||||
<div class="d-flex flex-nowrap px-2">
|
||||
<label
|
||||
id="paginationInputLabel-{{id}}"
|
||||
for="paginationInput-{{id}}"
|
||||
class="col-form-label me-2 ms-1 form-label"
|
||||
>Page</label>
|
||||
<input #i
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="form-control custom-pages-input"
|
||||
id="paginationInput-{{id}}"
|
||||
[value]="page"
|
||||
(keyup.enter)="selectPageStr(i.value)"
|
||||
(blur)="selectPageStr(i.value)"
|
||||
(input)="formatInput($any($event).target)"
|
||||
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
||||
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
||||
/>
|
||||
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
||||
of {{pagination.totalPages}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
<ng-container *ngIf="pagination && items.length > 0 && id == 'top' && pagination.totalPages > 1">
|
||||
<div class="jump-bar d-flex justify-content-center">
|
||||
<button *ngFor="let jumpKey of jumpBarKeys" class="btn btn-link" (click)="scrollTo(jumpKey)">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { AfterViewInit, Component, ContentChild, EventEmitter, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { from, Subject } from 'rxjs';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
@ -15,12 +18,15 @@ const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss']
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
|
||||
@Input() header: string = '';
|
||||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
@Input() pagination!: Pagination;
|
||||
|
||||
// Filter Code
|
||||
@Input() filterOpen!: EventEmitter<boolean>;
|
||||
/**
|
||||
* Should filtering be shown on the page
|
||||
*/
|
||||
@ -31,6 +37,9 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() trackByIdentity!: (index: number, item: any) => string;
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
|
||||
@Input() jumpBarKeys: Array<JumpKey> = [];
|
||||
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
@ -39,15 +48,13 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
|
||||
|
||||
|
||||
// Filter Code
|
||||
@Input() filterOpen!: EventEmitter<boolean>;
|
||||
|
||||
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
@ -55,7 +62,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
constructor(private seriesService: SeriesService, public utilityService: UtilityService) {
|
||||
constructor(private seriesService: SeriesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
}
|
||||
|
||||
@ -72,11 +80,28 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
const parent = this.document.querySelector('.card-container');
|
||||
if (parent == null) return;
|
||||
console.log('card divs', this.document.querySelectorAll('div[id^="jumpbar-index--"]'));
|
||||
console.log('cards: ', this.document.querySelectorAll('.card'));
|
||||
|
||||
Array.from(this.document.querySelectorAll('div')).forEach(elem => this.intersectionObserver.observe(elem));
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.intersectionObserver.disconnect();
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
console.log('interception: ', entries.filter(e => e.target.hasAttribute('no-observe')));
|
||||
|
||||
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
}
|
||||
@ -101,4 +126,50 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
// onScroll() {
|
||||
|
||||
// }
|
||||
|
||||
// onScrollDown() {
|
||||
// console.log('scrolled down');
|
||||
// }
|
||||
// onScrollUp() {
|
||||
// console.log('scrolled up');
|
||||
// }
|
||||
|
||||
|
||||
|
||||
scrollTo(jumpKey: JumpKey) {
|
||||
// TODO: Figure out how to do this
|
||||
|
||||
let targetIndex = 0;
|
||||
for(var i = 0; i < this.jumpBarKeys.length; i++) {
|
||||
if (this.jumpBarKeys[i].key === jumpKey.key) break;
|
||||
targetIndex += this.jumpBarKeys[i].size;
|
||||
}
|
||||
//console.log('scrolling to card that starts with ', jumpKey.key, + ' with index of ', targetIndex);
|
||||
|
||||
// Basic implementation based on itemsPerPage being the same.
|
||||
//var minIndex = this.pagination.currentPage * this.pagination.itemsPerPage;
|
||||
var targetPage = Math.max(Math.ceil(targetIndex / this.pagination.itemsPerPage), 1);
|
||||
//console.log('We are on page ', this.pagination.currentPage, ' and our target page is ', targetPage);
|
||||
if (targetPage === this.pagination.currentPage) {
|
||||
// Scroll to the element
|
||||
const elem = this.document.querySelector(`div[id="jumpbar-index--${targetIndex}"`);
|
||||
if (elem !== null) {
|
||||
elem.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectPageStr(targetPage + '');
|
||||
|
||||
// if (minIndex > targetIndex) {
|
||||
// // We need to scroll forward (potentially to another page)
|
||||
// } else if (minIndex < targetIndex) {
|
||||
// // We need to scroll back (potentially to another page)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +59,9 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
|
||||
NgbTooltipModule, // Card item
|
||||
NgbCollapseModule,
|
||||
NgbRatingModule,
|
||||
|
||||
//ScrollingModule,
|
||||
//InfiniteScrollModule,
|
||||
|
||||
|
||||
NgbOffcanvasModule, // Series Detail, action of cards
|
||||
@ -68,6 +71,8 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
|
||||
NgbProgressbarModule,
|
||||
NgxFileDropModule, // Cover Chooser
|
||||
PipeModule, // filter for BulkAddToCollectionComponent
|
||||
|
||||
|
||||
|
||||
|
||||
SharedModule, // IconAndTitleComponent
|
||||
|
@ -24,6 +24,7 @@
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
|
@ -18,6 +18,7 @@ import { SeriesService } from '../_services/series.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
@ -39,6 +40,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
filterActive: boolean = false;
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
|
||||
tabs: Array<{title: string, fragment: string, icon: string}> = [
|
||||
{title: 'Library', fragment: '', icon: 'fa-landmark'},
|
||||
{title: 'Recommended', fragment: 'recomended', icon: 'fa-award'},
|
||||
@ -100,6 +103,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
this.libraryName = names[this.libraryId];
|
||||
this.titleService.setTitle('Kavita - ' + this.libraryName);
|
||||
});
|
||||
|
||||
this.libraryService.getJumpBar(this.libraryId).subscribe(barDetails => {
|
||||
console.log('JumpBar: ', barDetails);
|
||||
this.jumpKeys = barDetails;
|
||||
});
|
||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
|
||||
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||
|
@ -448,13 +448,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.attachIntersectionObserverElem(event.target);
|
||||
|
||||
if (imagePage === this.pageNum) {
|
||||
Promise.all(Array.from(document.querySelectorAll('img'))
|
||||
Promise.all(Array.from(this.document.querySelectorAll('img'))
|
||||
.filter((img: any) => !img.complete)
|
||||
.map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; })))
|
||||
.then(() => {
|
||||
this.debugLog('[Initialization] All images have loaded from initial prefetch, initFinished = true');
|
||||
this.debugLog('[Image Load] ! Loaded current page !', this.pageNum);
|
||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
|
||||
// There needs to be a bit of time before we scroll
|
||||
if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) {
|
||||
this.scrollToCurrentPage();
|
||||
|
@ -99,7 +99,7 @@ form {
|
||||
|
||||
.dropdown {
|
||||
width: 100vw;
|
||||
height: calc(100vh - 57px); //header offset
|
||||
height: calc(100vh - 56px); //header offset
|
||||
background: var(--dropdown-overlay-color);
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
|
@ -65,9 +65,10 @@
|
||||
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-book-open">
|
||||
{{series.wordCount | compactNumber}} Words
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
|
||||
</ng-container>
|
||||
<ng-template #showPages>
|
||||
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
@ -75,6 +76,7 @@
|
||||
{{series.pages | number:''}} Pages
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user