MAL Interest Stacks (#2932)

This commit is contained in:
Joe Milazzo 2024-05-04 15:23:58 -05:00 committed by GitHub
parent 29eb65c783
commit b23300b1a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 4104 additions and 382 deletions

View file

@ -1,19 +1,6 @@
import {ScrobbleProvider} from "../_services/scrobbling.service";
import {AgeRating} from "./metadata/age-rating";
// Deprecated in v0.8, replaced with UserCollection
// export interface CollectionTag {
// id: number;
// title: string;
// promoted: boolean;
// /**
// * This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
// */
// coverImage: string;
// coverImageLocked: boolean;
// summary: string;
// }
export interface UserCollection {
id: number;
title: string;
@ -28,6 +15,7 @@ export interface UserCollection {
owner: string;
source: ScrobbleProvider;
sourceUrl: string | null;
totalSourceCount: number;
missingSeriesFromSource: string | null;
ageRating: AgeRating;
}

View file

@ -0,0 +1,19 @@
import { inject } from '@angular/core';
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({
name: 'safeUrl',
pure: true,
standalone: true
})
export class SafeUrlPipe implements PipeTransform {
private readonly dom: DomSanitizer = inject(DomSanitizer);
constructor() {}
transform(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null;
return this.dom.sanitize(SecurityContext.URL, value);
}
}

View file

@ -64,4 +64,8 @@ export class CollectionTagService {
if (isPromotionAction) return canPromote;
return true;
}
importStack(stack: MalStack) {
return this.httpClient.post(this.baseUrl + 'collection/import-stack', stack, TextResonse);
}
}

View file

@ -17,7 +17,10 @@ export enum EVENTS {
SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection',
/**
* When a Collection has been updated
*/
CollectionUpdated = 'CollectionUpdated',
/**
* A generic error that occurs during operations on the server
*/
@ -40,6 +43,10 @@ export enum EVENTS {
* A subtype of NotificationProgress that represents the underlying file being processed during a scan
*/
FileScanProgress = 'FileScanProgress',
/**
* A subtype of NotificationProgress that represents a single series being processed (into the DB)
*/
ScanProgress = 'ScanProgress',
/**
* A custom user site theme is added or removed during a scan
*/
@ -141,7 +148,7 @@ export class MessageHubService {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
//.withStatefulReconnect() // Needs @microsoft/signalr@8
.withStatefulReconnect()
.build();
this.hubConnection
@ -214,9 +221,9 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
this.hubConnection.on(EVENTS.CollectionUpdated, resp => {
this.messagesSource.next({
event: EVENTS.SeriesAddedToCollection,
event: EVENTS.CollectionUpdated,
payload: resp.body
});
});

View file

@ -10,7 +10,7 @@
<div class="offcanvas-body">
<ng-container *ngIf="CoverUrl as coverUrl">
<div style="width: 160px" class="mx-auto mb-3">
<app-image *ngIf="coverUrl" height="230px" width="160px" maxHeight="230px" objectFit="contain" [imageUrl]="coverUrl"></app-image>
<app-image *ngIf="coverUrl" height="230px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '230px'}" [imageUrl]="coverUrl"></app-image>
</div>
</ng-container>
@ -55,7 +55,7 @@
<div class="row g-0">
<div class="col-md-4">
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
</ng-container>
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>

View file

@ -5,37 +5,42 @@
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="!invited" [innerHTML]="t('description') | safeHtml"></p>
@if (!invited) {
<p [innerHTML]="t('description') | safeHtml"></p>
}
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
{{t('required-field')}}
@if (emailLink === '') {
<form [formGroup]="inviteForm">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</div>
</form>
}
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>{{t('setup-user-title')}}</h4>

View file

@ -48,51 +48,53 @@
</ng-template>
</li>
<li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{t(TabID.Series)}}</a>
<ng-template ngbNavContent>
@if (!isLoading) {
<div class="list-group">
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
@if (tag.source === ScrobbleProvider.Kavita) {
<li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{t(TabID.Series)}}</a>
<ng-template ngbNavContent>
@if (!isLoading) {
<div class="list-group">
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
</div>
</div>
</div>
</form>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div>
</form>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div>
<ul>
@for (item of series | filter: filterList; let i = $index; track item.id) {
<li class="list-group-item">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
<ul>
@for (item of series | filter: filterList; let i = $index; track item.id) {
<li class="list-group-item">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
}
</ul>
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
<div class="d-flex justify-content-center">
<ngb-pagination
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
}
</ul>
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
<div class="d-flex justify-content-center">
<ngb-pagination
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
}
</div>
}
</ng-template>
</li>
</div>
}
</ng-template>
</li>
}
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
@ -102,6 +104,43 @@
(resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
@if (tag.source !== ScrobbleProvider.Kavita) {
<li [ngbNavItem]="TabID.Info">
<a ngbNavLink>{{t(TabID.Info)}}</a>
<ng-template ngbNavContent>
<div class="row g-0 mb-2">
<div class="col-md-6">
<div>{{t('last-sync-title')}}</div>
<div>{{tag.lastSyncUtc | date:'shortDate' | defaultDate}}</div>
</div>
<div class="col-md-6">
<div>{{t('source-url-title')}}</div>
<a [href]="tag.sourceUrl | safeUrl" rel="noopener noreferrer" target="_blank">{{tag.sourceUrl}}</a>
</div>
</div>
<div class="row g-0 mb-2">
<div class="col-md-6">
<div>{{t('total-series-title')}}</div>
<div>{{tag.totalSourceCount | number}}</div>
</div>
<div class="col-md-6">
<div>{{t('missing-series-title')}}</div>
<div>{{tag.totalSourceCount - series.length}}</div>
</div>
</div>
@if (tag.missingSeriesFromSource !== null && (tag.totalSourceCount - series.length) > 0) {
<h6>{{t('missing-series-title')}}</h6>
<div class="row g-0">
<p [innerHTML]="tag.missingSeriesFromSource | safeHtml"></p>
</div>
}
</ng-template>
</li>
}
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>

View file

@ -24,26 +24,34 @@ import {LibraryService} from 'src/app/_services/library.service';
import {SeriesService} from 'src/app/_services/series.service';
import {UploadService} from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule, NgTemplateOutlet} from "@angular/common";
import {CommonModule, DatePipe, DecimalPipe, NgIf, NgTemplateOutlet} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
import {AccountService} from "../../../_services/account.service";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ReadMoreComponent} from "../../../shared/read-more/read-more.component";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
enum TabID {
General = 'general-tab',
CoverImage = 'cover-image-tab',
Series = 'series-tab'
Series = 'series-tab',
Info = 'info-tab'
}
@Component({
selector: 'app-edit-collection-tags',
standalone: true,
imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe],
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe, DatePipe, DefaultDatePipe, ReadMoreComponent, SafeHtmlPipe, SafeUrlPipe, MangaFormatPipe, NgIf, SentenceCasePipe, TagBadgeComponent, DecimalPipe],
templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -2,9 +2,9 @@
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)">
@if (total > 0 || suppressArchiveWarning) {
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageUrl"></app-image>
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageUrl"></app-image>
} @else if (total === 0 && !suppressArchiveWarning) {
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.errorImage"></app-image>
}
<div class="progress-banner">

View file

@ -1,17 +1,17 @@
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image>
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-0">
<ng-content select="[title]"></ng-content>
</h5>
<ng-container *ngIf="summary && summary.length > 0">
@if (summary && summary.length > 0) {
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
</ng-container>
}
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@
<div class="card-item-container card clickable">
<div class="overlay" (click)="handleClick()">
<ng-container>
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="data.coverUrl"></app-image>
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" [imageUrl]="data.coverUrl"></app-image>
</ng-container>

View file

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'list-item'">
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image>
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
<span class="download">
<app-download-indicator [download$]="download$"></app-download-indicator>

View file

@ -1,6 +1,6 @@
<div class="card-item-container card">
<div class="overlay">
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" classes="extreme-blur" [imageUrl]="imageUrl"></app-image>
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" classes="extreme-blur" [imageUrl]="imageUrl"></app-image>
<div class="card-overlay"></div>
<ng-container *ngIf="entity.title | safeHtml as info">

View file

@ -11,15 +11,34 @@
</div>
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
<div class="row mb-3" *ngIf="summary.length > 0">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="481px" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image>
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image [styles]="{'max-width': '481px'}" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image>
@if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
<div class="under-image">
<app-image [imageUrl]="collectionTag.source | providerImage"
width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}"
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'shortDate' | defaultDate })"></i>
</div>
}
</div>
<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.getActiveBreakpoint() < Breakpoint.Tablet ? 250 : 600"></app-read-more>
</div>
}
</div>
<hr>
</div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
<hr>
</div>
}
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter"

View file

@ -41,3 +41,11 @@ h2 {
margin-bottom: 0;
word-break: break-all;
}
.under-image {
background-color: var(--breadcrumb-bg-color);
color: white;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
text-align: center;
}

View file

@ -1,4 +1,4 @@
import {DOCUMENT, NgIf, NgStyle} from '@angular/common';
import {DatePipe, DOCUMENT, NgIf, NgStyle} from '@angular/common';
import {
AfterContentChecked,
ChangeDetectionStrategy,
@ -15,14 +15,14 @@ import {
} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
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 {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, KEY_CODES, 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';
@ -54,6 +54,12 @@ import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {AccountService} from "../../../_services/account.service";
import {User} from "../../../_models/user";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
@Component({
selector: 'app-collection-detail',
@ -61,7 +67,7 @@ import {User} from "../../../_models/user";
styleUrls: ['./collection-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective]
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip, SafeHtmlPipe, TranslocoDatePipe, DatePipe, DefaultDatePipe, ProviderImagePipe, ProviderNamePipe]
})
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -82,7 +88,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly utilityService = inject(UtilityService);
protected readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService);
@ -213,7 +219,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => {
if (event.event == EVENTS.SeriesAddedToCollection) {
if (event.event == EVENTS.CollectionUpdated) {
const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
if (collectionEvent.tagId === this.collectionTag.id) {
this.loadPage();
@ -326,4 +332,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.loadPage();
});
}
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly Breakpoint = Breakpoint;
}

View file

@ -9,9 +9,12 @@
<ul>
@for(stack of stacks; track stack.url) {
<li>
<div><a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a></div>
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount})}}</span></div>
<li class="mb-2">
<div>
<a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a>
<button class="btn btn-primary float-end" [disabled]="collectionMap && collectionMap.hasOwnProperty(stack.url)" (click)="importStack(stack)">Track</button>
</div>
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount | number})}}</span></div>
</li>
}
</ul>
@ -31,6 +34,9 @@
<!-- <div class="col-auto">-->
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
<!-- </div>-->
<div class="col-auto">
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
</div>
</div>
</ng-container>

View file

@ -1,10 +1,15 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {TranslocoDirective} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {CollectionTagService} from "../../../_services/collection-tag.service";
import {MalStack} from "../../../_models/collection/mal-stack";
import {UserCollection} from "../../../_models/collection-tag";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common";
@Component({
selector: 'app-import-mal-collection-modal',
@ -12,7 +17,8 @@ import {MalStack} from "../../../_models/collection/mal-stack";
imports: [
TranslocoDirective,
ReactiveFormsModule,
Select2Module
Select2Module,
DecimalPipe
],
templateUrl: './import-mal-collection-modal.component.html',
styleUrl: './import-mal-collection-modal.component.scss',
@ -21,19 +27,41 @@ import {MalStack} from "../../../_models/collection/mal-stack";
export class ImportMalCollectionModalComponent {
protected readonly ngbModal = inject(NgbActiveModal);
protected readonly collectionService = inject(CollectionTagService);
protected readonly cdRef = inject(ChangeDetectorRef);
private readonly collectionService = inject(CollectionTagService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
stacks: Array<MalStack> = [];
isLoading = true;
collectionMap: {[key: string]: UserCollection | MalStack} = {};
constructor() {
this.collectionService.getMalStacks().subscribe(stacks => {
this.stacks = stacks;
this.isLoading = false;
this.cdRef.markForCheck();
})
forkJoin({
allCollections: this.collectionService.allCollections(true),
malStacks: this.collectionService.getMalStacks()
}).subscribe(res => {
// Create a map on sourceUrl from collections so that if there are non-null sourceUrl (and source is MAL) then we can disable buttons
const collects = res.allCollections.filter(c => c.source === ScrobbleProvider.Mal && c.sourceUrl);
for(let col of collects) {
if (col.sourceUrl === null) continue;
this.collectionMap[col.sourceUrl] = col;
}
this.stacks = res.malStacks;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
importStack(stack: MalStack) {
this.collectionService.importStack(stack).subscribe(() => {
this.collectionMap[stack.url] = stack;
this.cdRef.markForCheck();
this.toastr.success(translate('toasts.stack-imported'));
})
}

View file

@ -23,71 +23,81 @@
</li>
</ng-container>
</ng-container>
<ng-container *ngIf="debugMode">
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
@if (debugMode) {
<ng-container>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item error">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item info">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item">
<div class="d-inline-flex">
</div>
</li>
<li class="list-group-item dark-menu-item error">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item info">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item">
<div class="d-inline-flex">
<span class="download">
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
<span class="visually-hidden" role="status">
10% downloaded
</span>
</span>
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
</div>
<div class="accent-text">PDFs</div>
</li>
</ng-container>
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
</div>
<div class="accent-text">PDFs</div>
</li>
</ng-container>
}
<!-- Progress Events-->
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
<ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
@if (message.subTitle !== '') {
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
}
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}</div>
}
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;" *ngIf="message.progress === 'indeterminate'">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
@if(message.progress === 'indeterminate') {
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
}
</div>
</li>
<ng-template #progressEvent>
@ -165,12 +175,17 @@
</ng-container>
<!-- Online Users -->
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
</li>
<li class="list-group-item dark-menu-item" *ngIf="debugMode">{{t('active-events-title')}} {{activeEvents}}</li>
</ng-container>
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
@if (onlineUsers.length > 1) {
<li class="list-group-item dark-menu-item">
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
</li>
}
@if (debugMode) {
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
}
}
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li>

View file

@ -15,10 +15,10 @@
}
::ng-deep .nav-events {
.popover-body {
min-width: 250px;
max-width: 250px;
min-width: 300px;
max-width: 300px;
padding: 0px;
box-shadow: 0px 0px 12px rgb(0 0 0 / 75%);
max-height: calc(100vh - 60px);
@ -42,7 +42,7 @@
width: 100%;
text-overflow: ellipsis;
overflow:hidden;
white-space:nowrap;
white-space:nowrap;
}
.btn:focus, .btn:hover {
@ -76,7 +76,7 @@
.update-available {
cursor: pointer;
i.fa {
color: var(--primary-color) !important;
}
@ -119,4 +119,4 @@
font-size: 11px;
position: absolute;
}
}
}

View file

@ -39,7 +39,7 @@
<div class="row mb-2">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="300px" maxHeight="400px" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
<app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
</div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<div class="row g-0 mb-3">

View file

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'reading-list-item'">
<div class="d-flex flex-row g-0 mb-2 reading-list-item">
<div class="pe-2">
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<app-image width="106px" [styles]="{'max-height': '125px'}" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
@if (item.pagesRead === 0 && item.pagesTotal > 0) {
<div class="not-read-badge" ></div>
}

View file

@ -58,7 +58,7 @@
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
</div>
<app-image height="100%" maxHeight="400px" objectFit="contain" background="none" [imageUrl]="seriesImage"></app-image>
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>

View file

@ -1,18 +1,19 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
ElementRef,
inject,
Input,
OnChanges,
Renderer2,
RendererStyleFlags2,
ViewChild
} from '@angular/core';
import { CoverUpdateEvent } from 'src/app/_models/events/cover-update-event';
import { ImageService } from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import {CoverUpdateEvent} from 'src/app/_models/events/cover-update-event';
import {ImageService} from 'src/app/_services/image.service';
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule, NgOptimizedImage} from "@angular/common";
import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
@ -48,26 +49,6 @@ export class ImageComponent implements OnChanges {
* Height of the image. If not defined, will not be applied
*/
@Input() height: string = '';
/**
* Max Width of the image. If not defined, will not be applied
*/
@Input() maxWidth: string = '';
/**
* Max Height of the image. If not defined, will not be applied
*/
@Input() maxHeight: string = '';
/**
* Border Radius of the image. If not defined, will not be applied
*/
@Input() borderRadius: string = '';
/**
* Object fit of the image. If not defined, will not be applied
*/
@Input() objectFit: string = '';
/**
* Background of the image. If not defined, will not be applied
*/
@Input() background: string = '';
/**
* If the image component should respond to cover updates
*/
@ -79,7 +60,7 @@ export class ImageComponent implements OnChanges {
/**
* A collection of styles to apply. This is useful if the parent component doesn't want to use no view encapsulation
*/
@Input() styles: string = '';
@Input() styles: {[key: string]: string} = {};
@Input() errorImage: string = this.imageService.errorImage;
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
@ -110,41 +91,25 @@ export class ImageComponent implements OnChanges {
}
ngOnChanges(): void {
if (this.width != '') {
if (this.width !== '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'width', this.width);
}
if (this.height != '') {
if (this.height !== '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'height', this.height);
}
if (this.maxWidth != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'max-width', this.maxWidth);
}
if (this.maxHeight != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'max-height', this.maxHeight);
}
if (this.borderRadius != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'border-radius', this.borderRadius);
}
if (this.objectFit != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'object-fit', this.objectFit);
}
if (this.background != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'background', this.background);
}
if (this.styles != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'styles', this.styles);
const styleKeys = Object.keys(this.styles);
if (styleKeys.length !== 0) {
styleKeys.forEach(key => {
this.renderer.setStyle(this.imgElem.nativeElement, key, this.styles[key], RendererStyleFlags2.Important);
});
}
if (this.classes != '') {
this.renderer.addClass(this.imgElem.nativeElement, this.classes);
}
this.cdRef.markForCheck();
}

View file

@ -1,7 +1,7 @@
<div class="tagbadge cursor clickable" *ngIf="person !== undefined">
<div class="d-flex">
<ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="staff.imageUrl"></app-image>
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}"[imageUrl]="staff.imageUrl"></app-image>
</ng-container>
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>

View file

@ -65,7 +65,7 @@ export class SideNavComponent implements OnInit {
homeActions = [
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
//{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
];
filterQuery: string = '';

View file

@ -1,17 +1,27 @@
<ng-container *ngIf="data$ | async as data">
@if (data$ | async; as data) {
<div class="card" style="width: 18rem;">
<div class="card-header text-center">
{{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i>
@if (description && description.length > 0) {
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" *ngFor="let item of data" (click)="doClick(item)">
<ng-container *ngIf="image && image(item) as url">
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
</ng-container>
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value | compactNumber}} {{label}}</span>
</li>
@for(item of data; track item) {
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" (click)="doClick(item)">
@if (image && image(item); as url) {
@if (url && url.length > 0) {
<app-image width="32px" [styles]="{'max-height': '32px'}" class="img-top me-1" [imageUrl]="url"></app-image>
}
}
{{item.name}}
@if (item.value >= 0) {
<span class="float-end">{{item.value | compactNumber}} {{label}}</span>
}
</li>
}
</ul>
</div>
</ng-container>
<ng-template #tooltip></ng-template>
}
<ng-template #tooltip>{{description}}</ng-template>

View file

@ -4,7 +4,7 @@ import { PieDataItem } from '../../_models/pie-data-item';
import { CompactNumberPipe } from '../../../_pipes/compact-number.pipe';
import { ImageComponent } from '../../../shared/image/image.component';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, NgClass, AsyncPipe } from '@angular/common';
import { NgClass, AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
@ -13,7 +13,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./stat-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip, NgFor, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective]
imports: [NgbTooltip, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective]
})
export class StatListComponent {

View file

@ -949,7 +949,7 @@
"general-tab": "General",
"metadata-tab": "Metadata",
"cover-tab": "Cover",
"info-tab": "Info",
"info-tab": "{{edit-series-modal.info-tab}}",
"progress-tab": "Progress",
"no-summary": "No Summary available.",
"writers-title": "{{series-metadata-detail.writers-title}}",
@ -1167,7 +1167,7 @@
"encode-as-description-part-2": "Can I Use WebP?",
"encode-as-description-part-3": "Can I Use AVIF?",
"encode-as-warning": "You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.",
"media-warning": "You must trigger the media conversion task in Tasks Tab.,",
"media-warning": "You must trigger the media conversion task in Tasks Tab.",
"encode-as-label": "Save Media As",
"encode-as-tooltip": "All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.",
"bookmark-dir-label": "Bookmarks Directory",
@ -1373,6 +1373,7 @@
"cancel": "{{common.cancel}}",
"general-tab": "General",
"cover-image-tab": "Cover Image",
"info-tab": "{{edit-series-modal.info-tab}}",
"series-tab": "Series",
"name-label": "Name",
"name-validation": "Name must be unique",
@ -1381,7 +1382,11 @@
"summary-label": "Summary",
"deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}",
"filter-label": "{{common.filter}}"
"filter-label": "{{common.filter}}",
"last-sync-title": "Last Sync:",
"source-url-title": "Source Url:",
"total-series-title": "Total Series:",
"missing-series-title": "Missing Series:"
},
"library-detail": {
@ -1423,7 +1428,9 @@
"no-data": "There are no items. Try adding a series.",
"no-data-filtered": "No items match your current filter.",
"title-alt": "Kavita - {{collectionName}} Collection",
"series-header": "Series"
"series-header": "Series",
"sync-progress": "Series Collected: {{title}}",
"last-sync": "Last Sync: {{date}}"
},
"all-collections": {
@ -1490,7 +1497,8 @@
"close": "{{common.close}}",
"users-online-count": "{{num}} Users online",
"active-events-title": "Active Events:",
"no-data": "Not much going on here"
"no-data": "Not much going on here",
"left-to-process": "Left to Process: {{leftToProcess}}"
},
"shortcuts-modal": {
@ -2176,7 +2184,8 @@
"collections-unpromoted": "Collections un-promoted",
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode"
"pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported"
},