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:
Joseph Milazzo 2022-05-30 16:50:12 -05:00 committed by GitHub
parent 64c0b5a71e
commit 742cfd3293
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 319 additions and 120 deletions

View file

@ -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)

View file

@ -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);
}
}

View 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; }
}

View file

@ -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.

View file

@ -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

View file

@ -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);

View file

@ -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
};
}
}

View file

@ -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)