Polish Pass 1 (#4084)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-10-08 08:47:41 -05:00 committed by GitHub
parent a3c8c9be33
commit 5f744fa2fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1082 additions and 331 deletions

1
.gitignore vendored
View File

@ -537,6 +537,7 @@ UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*
UI/Web/.angular/
BenchmarkDotNet.Artifacts
.claude/
API.Tests/Services/Test Data/ImageService/**/*_output*

View File

@ -581,8 +581,11 @@ public class OidcServiceTests(ITestOutputHelper outputHelper): AbstractDbTest(ou
await userManager.CreateAsync(user);
await userManager.CreateAsync(defaultAdmin);
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), unitOfWork, mapper, Substitute.For<ILocalizationService>());
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, unitOfWork, accountService, Substitute.For<IEmailService>());
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(),
unitOfWork, mapper, Substitute.For<ILocalizationService>());
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, unitOfWork,
accountService, Substitute.For<IEmailService>());
return (oidcService, user, accountService, userManager);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -274,14 +274,14 @@ public class ReaderController : BaseApiController
if (info.IsSpecial)
{
info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName);
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume))
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Parser.LooseLeafVolume))
{
info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
}
else
{
info.Subtitle = await _localizationService.Translate(User.GetUserId(), "volume-num", info.VolumeNumber);
if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter))
if (!info.ChapterNumber.Equals(Parser.DefaultChapter))
{
info.Subtitle += " " + ReaderService.FormatChapterName(info.LibraryType, true, true) +
info.ChapterNumber;

View File

@ -32,4 +32,8 @@ public sealed record LicenseInfoDto
/// A license is stored within Kavita
/// </summary>
public bool HasLicense { get; set; }
/// <summary>
/// InstallId which can be given to support
/// </summary>
public string InstallId { get; set; }
}

View File

@ -58,6 +58,8 @@ public interface IReadingListRepository
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId);
Task<bool> AnyUserReadingProgressAsync(int readingListId, int userId);
Task<ReadingListItemDto?> GetContinueReadingPoint(int readingListId, int userId);
}
public class ReadingListRepository : IReadingListRepository
@ -357,13 +359,128 @@ public class ReadingListRepository : IReadingListRepository
.SingleOrDefaultAsync();
}
public async Task<bool> AnyUserReadingProgressAsync(int readingListId, int userId)
{
// Since the list is already created, we can assume RBS doesn't need to apply
var chapterIdsQuery = _context.ReadingListItem
.Where(s => s.ReadingListId == readingListId)
.Select(s => s.ChapterId)
.AsQueryable();
return await _context.AppUserProgresses
.Where(p => chapterIdsQuery.Contains(p.ChapterId) && p.AppUserId == userId)
.AsNoTracking()
.AnyAsync();
}
public async Task<ReadingListItemDto?> GetContinueReadingPoint(int readingListId, int userId)
{
var userLibraries = _context.Library.GetUserLibraries(userId);
var query = _context.ReadingListItem
.Where(rli => rli.ReadingListId == readingListId)
.Join(_context.Chapter, rli => rli.ChapterId, chapter => chapter.Id, (rli, chapter) => new
{
ReadingListItem = rli,
Chapter = chapter,
})
.Join(_context.Volume, x => x.ReadingListItem.VolumeId, volume => volume.Id, (x, volume) => new
{
x.ReadingListItem,
x.Chapter,
Volume = volume
})
.Join(_context.Series, x => x.ReadingListItem.SeriesId, series => series.Id, (x, series) => new
{
x.ReadingListItem,
x.Chapter,
x.Volume,
Series = series
})
.Where(x => userLibraries.Contains(x.Series.LibraryId))
.GroupJoin(_context.AppUserProgresses.Where(p => p.AppUserId == userId),
x => x.ReadingListItem.ChapterId,
progress => progress.ChapterId,
(x, progressGroup) => new
{
x.ReadingListItem,
x.Chapter,
x.Volume,
x.Series,
ProgressGroup = progressGroup
})
.SelectMany(
x => x.ProgressGroup.DefaultIfEmpty(),
(x, progress) => new
{
x.ReadingListItem,
x.Chapter,
x.Volume,
x.Series,
Progress = progress,
PagesRead = progress != null ? progress.PagesRead : 0,
HasProgress = progress != null,
IsPartiallyRead = progress != null && progress.PagesRead > 0 && progress.PagesRead < x.Chapter.Pages,
IsUnread = progress == null || progress.PagesRead == 0
})
.OrderBy(x => x.ReadingListItem.Order);
// First try to find a partially read item then the first unread item
var item = await query
.OrderBy(x => x.IsPartiallyRead ? 0 : x.IsUnread ? 1 : 2)
.ThenBy(x => x.ReadingListItem.Order)
.FirstOrDefaultAsync();
if (item == null) return null;
// Map to DTO
var library = await _context.Library
.Where(l => l.Id == item.Series.LibraryId)
.Select(l => new { l.Name, l.Type })
.FirstAsync();
var dto = new ReadingListItemDto
{
Id = item.ReadingListItem.Id,
ChapterId = item.ReadingListItem.ChapterId,
Order = item.ReadingListItem.Order,
SeriesId = item.ReadingListItem.SeriesId,
SeriesName = item.Series.Name,
SeriesFormat = item.Series.Format,
PagesTotal = item.Chapter.Pages,
PagesRead = item.PagesRead,
ChapterNumber = item.Chapter.Range,
VolumeNumber = item.Volume.Name,
LibraryId = item.Series.LibraryId,
VolumeId = item.Volume.Id,
ReadingListId = item.ReadingListItem.ReadingListId,
ReleaseDate = item.Chapter.ReleaseDate,
LibraryType = library.Type,
ChapterTitleName = item.Chapter.TitleName,
LibraryName = library.Name,
FileSize = item.Chapter.Files.Sum(f => f.Bytes), // TODO: See if we can put FileSize on the chapter in future
Summary = item.Chapter.Summary,
IsSpecial = item.Chapter.IsSpecial,
LastReadingProgressUtc = item.Progress?.LastModifiedUtc
};
dto.Title = ReadingListService.FormatTitle(dto);
return dto;
}
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId, UserParams? userParams = null)
{
var userLibraries = _context.Library.GetUserLibraries(userId);
var query = _context.ReadingListItem
.Where(s => s.ReadingListId == readingListId)
.Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new
.Where(s => s.ReadingListId == readingListId)
.Join(_context.Chapter,
s => s.ChapterId,
chapter => chapter.Id,
(data, chapter) => new
{
TotalPages = chapter.Pages,
ChapterNumber = chapter.Range,
@ -373,9 +490,11 @@ public class ReadingListRepository : IReadingListRepository
FileSize = chapter.Files.Sum(f => f.Bytes),
chapter.Summary,
chapter.IsSpecial
})
.Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new
.Join(_context.Volume,
s => s.ReadingListItem.VolumeId,
volume => volume.Id,
(data, volume) => new
{
data.ReadingListItem,
data.TotalPages,
@ -388,59 +507,68 @@ public class ReadingListRepository : IReadingListRepository
VolumeId = volume.Id,
VolumeNumber = volume.Name,
})
.Join(_context.Series, s => s.ReadingListItem.SeriesId, series => series.Id,
(data, s) => new
{
SeriesName = s.Name,
SeriesFormat = s.Format,
s.LibraryId,
data.ReadingListItem,
data.TotalPages,
data.ChapterNumber,
data.VolumeNumber,
data.VolumeId,
data.ReleaseDate,
data.ChapterTitleName,
data.FileSize,
data.Summary,
data.IsSpecial,
LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(),
LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single()
})
.Select(data => new ReadingListItemDto()
.Join(_context.Series,
s => s.ReadingListItem.SeriesId,
series => series.Id,
(data, s) => new
{
Id = data.ReadingListItem.Id,
ChapterId = data.ReadingListItem.ChapterId,
Order = data.ReadingListItem.Order,
SeriesId = data.ReadingListItem.SeriesId,
SeriesName = data.SeriesName,
SeriesFormat = data.SeriesFormat,
PagesTotal = data.TotalPages,
ChapterNumber = data.ChapterNumber,
VolumeNumber = data.VolumeNumber,
LibraryId = data.LibraryId,
VolumeId = data.VolumeId,
ReadingListId = data.ReadingListItem.ReadingListId,
ReleaseDate = data.ReleaseDate,
LibraryType = data.LibraryType,
ChapterTitleName = data.ChapterTitleName,
LibraryName = data.LibraryName,
FileSize = data.FileSize,
Summary = data.Summary,
IsSpecial = data.IsSpecial
SeriesName = s.Name,
SeriesFormat = s.Format,
s.LibraryId,
data.ReadingListItem,
data.TotalPages,
data.ChapterNumber,
data.VolumeNumber,
data.VolumeId,
data.ReleaseDate,
data.ChapterTitleName,
data.FileSize,
data.Summary,
data.IsSpecial,
LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(),
LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single()
})
.Where(o => userLibraries.Contains(o.LibraryId))
.OrderBy(rli => rli.Order)
.AsSplitQuery();
.GroupJoin(_context.AppUserProgresses.Where(p => p.AppUserId == userId),
data => data.ReadingListItem.ChapterId,
progress => progress.ChapterId,
(data, progressGroup) => new { Data = data, ProgressGroup = progressGroup })
.SelectMany(
x => x.ProgressGroup.DefaultIfEmpty(),
(x, progress) => new ReadingListItemDto()
{
Id = x.Data.ReadingListItem.Id,
ChapterId = x.Data.ReadingListItem.ChapterId,
Order = x.Data.ReadingListItem.Order,
SeriesId = x.Data.ReadingListItem.SeriesId,
SeriesName = x.Data.SeriesName,
SeriesFormat = x.Data.SeriesFormat,
PagesTotal = x.Data.TotalPages,
ChapterNumber = x.Data.ChapterNumber,
VolumeNumber = x.Data.VolumeNumber,
LibraryId = x.Data.LibraryId,
VolumeId = x.Data.VolumeId,
ReadingListId = x.Data.ReadingListItem.ReadingListId,
ReleaseDate = x.Data.ReleaseDate,
LibraryType = x.Data.LibraryType,
ChapterTitleName = x.Data.ChapterTitleName,
LibraryName = x.Data.LibraryName,
FileSize = x.Data.FileSize,
Summary = x.Data.Summary,
IsSpecial = x.Data.IsSpecial,
PagesRead = progress != null ? progress.PagesRead : 0,
LastReadingProgressUtc = progress != null ? progress.LastModifiedUtc : null
})
.Where(o => userLibraries.Contains(o.LibraryId))
.OrderBy(rli => rli.Order)
.AsSplitQuery();
if (userParams != null)
{
query = query
.Skip((userParams.PageNumber - 1) * userParams.PageSize) // NOTE: PageNumber starts at 1 with PagedList, so copy logic here
.Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize);
}
var items = await query.ToListAsync();
foreach (var item in items)
@ -448,22 +576,6 @@ public class ReadingListRepository : IReadingListRepository
item.Title = ReadingListService.FormatTitle(item);
}
// Attach progress information
var fetchedChapterIds = items.Select(i => i.ChapterId);
var progresses = await _context.AppUserProgresses
.Where(p => fetchedChapterIds.Contains(p.ChapterId))
.AsNoTracking()
.ToListAsync();
foreach (var progress in progresses)
{
var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId);
if (progressItem == null) continue;
progressItem.PagesRead = progress.PagesRead;
progressItem.LastReadingProgressUtc = progress.LastModifiedUtc;
}
return items;
}

View File

@ -596,13 +596,18 @@ public class OpdsService : IOpdsService
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0 && i.PagesRead != i.PagesTotal) ??
items.FirstOrDefault(i => i.PagesRead == 0 && i.PagesRead != i.PagesTotal);
if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber)
var anyProgress = await _unitOfWork.ReadingListRepository.AnyUserReadingProgressAsync(readingListId, userId);
if (anyProgress)
{
await AddContinueReadingPoint(firstReadReadingListItem, feed, request);
var firstReadReadingListItem = await _unitOfWork.ReadingListRepository.GetContinueReadingPoint(readingListId, userId);
if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber)
{
await AddContinueReadingPoint(firstReadReadingListItem, feed, request);
}
}
foreach (var item in items)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId);
@ -1003,7 +1008,7 @@ public class OpdsService : IOpdsService
var pageNumber = Math.Max(list.CurrentPage, 1);
if (pageNumber > 1)
if (pageNumber > FirstPageNumber)
{
feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1)));
}

View File

@ -265,8 +265,6 @@ public class LicenseService(
if (cacheValue.HasValue) return cacheValue.Value;
}
// TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking
try
{
var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
@ -286,17 +284,25 @@ public class LicenseService(
.All(r => new Version(r.UpdateVersion) <= BuildInfo.Version);
response.HasLicense = hasLicense;
response.InstallId = HashUtil.ServerToken();
// Cache if the license is valid here as well
var licenseProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await licenseProvider.SetAsync(CacheKey, response.IsActive, _licenseCacheTimeout);
// TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking
if (response is {IsCancelled: true, IsActive: false})
{
//logger.LogWarning("Kavita+ License is no longer active, removing Server registration");
}
// Cache the license info if IsActive and ExpirationDate > DateTime.UtcNow + 2
if (response.IsActive && response.ExpirationDate > DateTime.UtcNow.AddDays(2))
{
await licenseInfoProvider.SetAsync(LicenseInfoCacheKey, response, _licenseCacheTimeout);
}
return response;
}
catch (FlurlHttpException e)

View File

@ -6,4 +6,5 @@ export interface LicenseInfo {
registeredEmail: string;
totalMonthsSubbed: number;
hasLicense: boolean;
installId: string;
}

View File

@ -37,7 +37,6 @@ export class FontService {
}
getFontFace(font: EpubFont): FontFace {
// TODO: We need to refactor this so that we loadFonts with an array, fonts have an id to remove them, and we don't keep populating the document
if (font.provider === FontProvider.System) {
return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`);
}

View File

@ -179,6 +179,14 @@
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supportId-label')">
<ng-template #view>
{{licInfo.installId}}
</ng-template>
</app-setting-item>
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->

View File

@ -7,7 +7,7 @@
<div ngbAccordion class="mb-2">
<div ngbAccordionItem class="p-2">
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
<button ngbAccordionButton class="accordion-button">
<h4 class="changelog-header">{{update.updateTitle}}&nbsp;
@if (update.isOnNightlyInRelease) {
<span class="badge bg-secondary">{{t('nightly', {version: update.currentVersion})}}</span>
@ -17,6 +17,7 @@
<span class="badge bg-secondary">{{t('available')}}</span>
}
</h4>
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h2>
<div ngbAccordionCollapse>

View File

@ -6,3 +6,7 @@
.changelog-header {
color: var(--body-text-color);
}
.accordion-button {
color: var(--body-text-color);
}

View File

@ -37,7 +37,8 @@
@if(showPageLink()) {
<span class="badge bg-secondary clickable" (click)="loadAnnotation()">{{t('page-num', {page: annotation().pageNumber})}}</span>
<!-- Page numbers are incremented by 1 in the UI -->
<span class="badge bg-secondary clickable" (click)="loadAnnotation()">{{t('page-num', {page: annotation().pageNumber + 1})}}</span>
}

View File

@ -89,6 +89,10 @@ export class AnnotationCardComponent {
* If enabled, listens to annotation updates
*/
listedToUpdates = input<boolean>(false);
/**
* If the card is rendered inside the book reader. Used for styling the confirm button
*/
inBookReader = input<boolean>(false);
selected = input<boolean>(false);
@Output() delete = new EventEmitter();
@ -157,7 +161,10 @@ export class AnnotationCardComponent {
}
async deleteAnnotation() {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'), {
...this.confirmService.defaultConfirm,
bookReader: this.inBookReader(),
})) return;
const annotation = this.annotation();
if (!annotation) return;

View File

@ -36,6 +36,7 @@
(delete)="handleDelete(annotation)"
(navigate)="handleNavigateTo($event)"
[forceSize]="false"
[inBookReader]="true"
/>
}
@empty {

View File

@ -2259,11 +2259,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
async viewAnnotations() {
await this.epubMenuService.openViewAnnotationsDrawer((annotation: Annotation) => {
const currentPageNum = this.pageNum();
if (this.pageNum() != annotation.pageNumber) {
this.setPageNum(annotation.pageNumber);
}
if (annotation.xPath != null) {
this.adhocPageHistory.push({page: currentPageNum, scrollPart: this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath)});
this.loadPage(annotation.xPath);
}
});

View File

@ -6,6 +6,7 @@
<h2 class="accordion-header" ngbAccordionHeader>
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
{{t('general-settings-title')}}
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h2>
<div ngbAccordionCollapse>
@ -68,6 +69,7 @@
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
{{t('reader-settings-title')}}
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h2>
<div ngbAccordionCollapse>
@ -157,6 +159,7 @@
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
{{t('color-theme-title')}}
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h2>
<div ngbAccordionCollapse>

View File

@ -310,7 +310,6 @@ export class EditSeriesModalComponent implements OnInit {
this.volumeCollapsed[v.name] = true;
});
this.seriesVolumes.forEach(vol => {
//.sort(this.utilityService.sortChapters) (no longer needed, all data is sorted on the backend)
vol.volumeFiles = vol.chapters?.map((c: Chapter) => c.files.map((f: any) => {
// TODO: Identify how to fix this hack
f.chapter = c.range;

View File

@ -241,7 +241,7 @@ export class CardItemComponent implements OnInit {
}
} else if (this.utilityService.isSeries(this.entity)) {
this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name);
} else if (this.entity.hasOwnProperty('expectedDate')) {
} else if (this.entity.hasOwnProperty('expectedDate')) { // Upcoming Chapter Entity
this.suppressArchiveWarning = true;
this.imageUrl = '';
const nextDate = (this.entity as NextExpectedChapter);
@ -250,7 +250,6 @@ export class CardItemComponent implements OnInit {
// this.overlayInformation = `
// <i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
// <div>${tokens[0]}</div><div>${tokens[1]}</div>`;
// // todo: figure out where this caller is
this.centerOverlay = true;
if (nextDate.expectedDate) {

View File

@ -19,7 +19,7 @@ $image-height: 160px;
position: relative;
display: flex;
background-color: hsl(0deg 0% 0% / 12%);
/* TODO: Robbie fix this hack */
.missing-img {
align-self: center;
display: flex;

View File

@ -86,7 +86,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 170 : 200" />
<app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop ? 170 : 200" />
</div>
<div class="mt-2">

View File

@ -61,7 +61,7 @@ import {
} from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component";
import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component";
import {hasAnyCast} from "../_models/common/i-has-cast";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
import {CoverUpdateEvent} from "../_models/events/cover-update-event";
import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
@ -156,7 +156,7 @@ export class ChapterDetailComponent implements OnInit {
protected readonly AgeRating = AgeRating;
protected readonly TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
protected readonly UserBreakpoint = UserBreakpoint;
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;

View File

@ -39,7 +39,7 @@
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
@if (summary.length > 0) {
<div class="mb-2">
<app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200" />
<app-read-more [text]="summary" [maxLength]="utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop ? 170 : 200" />
</div>
@if (collectionTag.source !== ScrobbleProvider.Kavita) {

View File

@ -8,7 +8,7 @@ import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {UserCollection} from 'src/app/_models/collection-tag';
import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
@ -90,7 +90,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
private readonly metadataService = inject(MetadataService);
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly Breakpoint = Breakpoint;
protected readonly UserBreakpoint = UserBreakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;

View File

@ -26,7 +26,7 @@
<div class="subtitle">
{{chapterTitleLabel()}}
@if (totalSeriesPages > 0) {
<span>{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</span>
<span class="ms-2">{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</span>
}
</div>
</div>

View File

@ -501,7 +501,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return this.bookmarks[this.pageNum];
}
return chapterInfo?.chapterTitle ?? '';
return chapterInfo?.chapterTitle || chapterInfo?.subtitle || '';
});
@ -835,7 +835,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
//send the current width override value to the label
this.widthOverrideLabel$ = this.readerSettings$?.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
map(values => parseInt(values.widthSlider)),
map(widthOverride => (isNaN(widthOverride)) ? '' : widthOverride + '%'),
takeUntilDestroyed(this.destroyRef)
);
}

View File

@ -24,8 +24,9 @@
@if (fileToProcess.validateSummary; as summary) {
<div ngbAccordionItem>
<h5 ngbAccordionHeader>
<button ngbAccordionButton>
<button ngbAccordionButton class="accordion-button">
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }" />
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h5>
<div ngbAccordionCollapse>
@ -48,8 +49,9 @@
@if (fileToProcess.dryRunSummary; as summary) {
<div ngbAccordionItem>
<h5 ngbAccordionHeader>
<button ngbAccordionButton>
<button ngbAccordionButton class="accordion-button">
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }" />
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h5>
<div ngbAccordionCollapse>
@ -70,8 +72,9 @@
@if (fileToProcess.finalizeSummary; as summary) {
<div ngbAccordionItem>
<h5 ngbAccordionHeader>
<button ngbAccordionButton>
<button ngbAccordionButton class="accordion-button">
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }" />
<i class="fas fa-chevron-up" aria-hidden="true"></i>
</button>
</h5>
<div ngbAccordionCollapse>

View File

@ -100,7 +100,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="readingList.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200" />
<app-read-more [text]="readingList.summary || ''" [maxLength]="utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop ? 170 : 200" />
</div>
<div class="mt-2 upper-details">

View File

@ -1,10 +1,10 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, inject, OnInit, ViewChild } from '@angular/core';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {AsyncPipe, DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common';
import {DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingList, ReadingListInfo, ReadingListItem} from 'src/app/_models/reading-list';
@ -65,7 +65,7 @@ enum TabID {
imports: [CardActionablesComponent, ImageComponent, NgbDropdown,
NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent,
LoadingComponent, DraggableOrderedListComponent,
ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule,
ReadingListItemComponent, NgClass, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule,
NgbNav, NgbNavContent, NgbNavLink, NgbTooltip,
RouterLink, VirtualScrollerModule, NgStyle, NgbNavOutlet, NgbNavItem, PromotedIconComponent, DefaultValuePipe, DetailsTabComponent]
})
@ -75,6 +75,7 @@ export class ReadingListDetailComponent implements OnInit {
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
protected readonly UserBreakpoint = UserBreakpoint;
protected readonly TabID = TabID;
protected readonly encodeURIComponent = encodeURIComponent;

View File

@ -32,7 +32,7 @@
@if (showOidcButton()) {
<a
class="btn btn-outline-primary mt-2 d-flex align-items-center gap-2"
class="btn btn-outline-primary mt-2 d-flex justify-content-center align-items-center gap-2"
href="oidc/login"
>
<app-image height="36px" width="36px" [imageUrl]="'assets/icons/open-id-connect-logo.svg'" [styles]="{'object-fit': 'contains'}" />

View File

@ -109,7 +109,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200" />
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop ? 170 : 200" />
</div>
<div class="mt-2 upper-details">

View File

@ -38,7 +38,7 @@ import {
EditSeriesModalComponent
} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Device} from 'src/app/_models/device/device';
import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
@ -158,7 +158,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
protected readonly SettingsTabId = SettingsTabId;
protected readonly FilterField = FilterField;
protected readonly AgeRating = AgeRating;
protected readonly Breakpoint = Breakpoint;
protected readonly UserBreakpoint = UserBreakpoint;
protected readonly encodeURIComponent = encodeURIComponent;
private readonly destroyRef = inject(DestroyRef);
@ -866,9 +866,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.volumes = detail.volumes;
this.storyChapters = detail.storylineChapters;
this.totalSize.set(detail.volumes.reduce((sum, v) => sum + v.chapters.reduce((volumeSum, c) => {
return volumeSum + c.files.reduce((chapterSum, f) => chapterSum + f.bytes , 0)
}, 0), 0));
const uniqueChapters = Array.from(
new Map([...detail.chapters, ...detail.volumes.flatMap(v => v.chapters)]
.map(c => [c.id, c])).values()
);
this.totalSize.set(uniqueChapters
.flatMap(c => c.files)
.reduce((sum, f) => sum + f.bytes, 0));
this.storylineItems = [];
const v = this.volumes.map(v => {

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; prefix: 'multi-check-box-form'">
<div class="d-flex justify-content-between">
<div class="w-75">
<div class="w-70">
<h4>{{title()}}</h4>
<span class="text-muted d-block">{{tooltip()}}</span>
</div>

View File

@ -1,17 +1,17 @@
<ng-container *transloco="let t; prefix: 'badge-expander'">
<div class="badge-expander">
<div class="content">
@for(item of visibleItems; track item; let i = $index; let last = $last) {
@for(item of visibleItems(); track item; let i = $index; let last = $last) {
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i, last: last }" />
@if (!last && includeComma) {
@if (!last && includeComma()) {
<span>, </span>
}
} @empty {
{{null | defaultValue}}
}
@if (!isCollapsed && itemsLeft !== 0) {
<a href="javascript:void(0);" type="button" class="dark-exempt btn-icon ms-1" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
{{t('more-items', {count: itemsLeft})}}
@if (isCollapsed() && itemsLeft() !== 0) {
<a href="javascript:void(0);" type="button" class="dark-exempt btn-icon ms-1" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed()">
{{t('more-items', {count: itemsLeft()})}}
</a>
}
</div>

View File

@ -1,11 +1,9 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Component, computed,
ContentChild, EventEmitter,
inject,
Input, OnChanges,
OnInit, Output, SimpleChanges,
input,
OnInit, Output, signal,
TemplateRef
} from '@angular/core';
import {NgTemplateOutlet} from "@angular/common";
@ -19,59 +17,49 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
styleUrls: ['./badge-expander.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BadgeExpanderComponent implements OnInit, OnChanges {
export class BadgeExpanderComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
@Input() items: Array<any> = [];
@Input() itemsTillExpander: number = 4;
@Input() allowToggle: boolean = true;
@Input() includeComma: boolean = true;
items = input.required<any[]>();
itemsTillExpander = input(4);
allowToggle = input(true);
includeComma = input(true);
/**
* If should be expanded by default. Defaults to false.
* If the list should be expanded by default. Defaults to false.
*/
@Input() defaultExpanded: boolean = false;
defaultExpanded = input(false);
/**
* Invoked when the "and more" is clicked
*/
@Output() toggle = new EventEmitter<void>();
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
isCollapsed = signal<boolean | undefined>(undefined);
visibleItems = computed(() => {
const allItems = this.items();
const isCollapsed = this.isCollapsed();
const cutOff = this.itemsTillExpander();
visibleItems: Array<any> = [];
isCollapsed: boolean = false;
if (!isCollapsed) return allItems;
get itemsLeft() {
if (this.defaultExpanded) return 0;
return allItems.slice(0, cutOff);
});
itemsLeft = computed(() => {
const allItems = this.items();
const visibleItems = this.visibleItems();
return Math.max(this.items.length - this.itemsTillExpander, 0);
}
return allItems.length - visibleItems.length;
});
ngOnInit(): void {
if (this.defaultExpanded) {
this.isCollapsed = false;
this.visibleItems = this.items;
this.cdRef.markForCheck();
return;
}
this.visibleItems = this.items.slice(0, this.itemsTillExpander);
this.cdRef.markForCheck();
}
ngOnChanges(changes: SimpleChanges) {
this.visibleItems = this.items.slice(0, this.itemsTillExpander);
this.cdRef.markForCheck();
this.isCollapsed.set(!this.defaultExpanded());
}
toggleVisible() {
this.toggle.emit();
if (!this.allowToggle) return;
if (!this.allowToggle()) return;
this.isCollapsed = !this.isCollapsed;
this.visibleItems = this.items;
this.cdRef.markForCheck();
this.isCollapsed.update(x => !x);
}
}

View File

@ -9,7 +9,7 @@
}
&::-webkit-scrollbar {
background-color: transparent; /*make scrollbar space invisible */
background-color: transparent;
width: inherit;
display: none;
visibility: hidden;
@ -17,7 +17,7 @@
}
&::-webkit-scrollbar-thumb {
background: transparent; /*makes it invisible when not hovering*/
background: transparent;
}
&:hover {

View File

@ -90,7 +90,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 170 : 200" />
<app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop ? 170 : 200" />
</div>
<div class="mt-2">

View File

@ -53,7 +53,7 @@ import {IHasCast} from "../_models/common/i-has-cast";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {Genre} from "../_models/metadata/genre";
@ -194,7 +194,7 @@ export class VolumeDetailComponent implements OnInit {
protected readonly AgeRating = AgeRating;
protected readonly TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
protected readonly UserBreakpoint = UserBreakpoint;
protected readonly encodeURIComponent = encodeURIComponent;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;

View File

@ -48,7 +48,7 @@
"provider-name-tooltip": "Name shown on the login screen",
"defaults-title": "Defaults",
"defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off",
"defaults-requirement": "The following settings are used when a user is registered via OIDC while Sync User Settings is turned off",
"default-include-unknowns-label": "Include unknowns",
"default-include-unknowns-tooltip": "Include unknown age ratings",
"default-age-restriction-label": "Age rating",
@ -815,6 +815,7 @@
"faq-title": "FAQ",
"total-subbed-months-label": "Total Months Subscribed",
"email-label": "Registered Email",
"supportId-label": "Support Id",
"license-mismatch": "License may be registered to another Kavita instance. Re-register to fix.",
"k+-license-overwrite": "License is registered to another Kavita instance. This can happen on re-installation and rarely due to system updates. Select overwrite to force this instance to register with Kavita+.",
@ -2180,7 +2181,7 @@
"next": "{{import-cbl-modal.next}}",
"save": "{{common.save}}",
"import-description": "Upload a file you, or someone else has exported to replace or merge with your current settings.",
"import-description": "Upload a file you, or someone else, has exported to replace or merge with your current settings.",
"select-files-warning": "You must upload a json file to continue",
"invalid-file": "Failed to parse your file, check your input",
"file-no-valid-content": "Your import did not contain any meaningful data to continue with",

View File

@ -1,36 +1,53 @@
.accordion-header {
font-weight: bold;
color: var(--accordion-header-text-color);
color: var(--accordion-header-text-color);
}
.accordion-item {
.accordion-item {
background-color: var(--accordion-body-bg-color);
color: var(--accordion-body-text-color);
border-color: var(--accordion-body-border-color);
div[role="tabpanel"] {
background-color: var(--accordion-header-bg-color);
background-color: var(--accordion-header-bg-color);
.accordion-body {
background-color: var(--accordion-active-body-bg-color);
background-color: var(--accordion-active-body-bg-color);
}
}
}
.accordion-button {
&:not(.collapsed) {
color: var(--accordion-header-text-color);
background-color: var(--accordion-body-bg-color);
box-shadow: var(--accordion-body-box-shadow);
display: flex;
justify-content: space-between;
&::after {
content: none;
}
&:not(.collapsed) {
color: var(--accordion-header-text-color);
background-color: var(--accordion-body-bg-color);
box-shadow: var(--accordion-body-box-shadow);
i {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
}
&.collapsed {
color: var(--accordion-header-collapsed-text-color);
background-color: var(--accordion-header-collapsed-bg-color);
i {
transition: transform 0.3s ease;
}
&.collapsed {
color: var(--accordion-header-collapsed-text-color);
background-color: var(--accordion-header-collapsed-bg-color);
}
}
&:focus {
border-color: var(--accordion-button-focus-border-color);
box-shadow: var(--accordion-button-focus-box-shadow);
}
&:focus {
border-color: var(--accordion-button-focus-border-color);
box-shadow: var(--accordion-button-focus-box-shadow);
}
}