Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

* Hooked up the ability to view the available locales and switch to them.

* Made the localization service languages be derived from what's in langs/ directory.

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

* Fixed the bug where dashboard wasn't loading and made it so language switching is working.

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

* Converted user-stats-info-cards.component.html

* Converted user-stats.component.html

* Converted top-readers.component.html

* Converted some pipes and ensure translation is loaded before the app.

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

* Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita.

* Converted Series detail

* Lots more converted

* Lots more converted & hooked up the ability to flatten during prod build the language files.

* Lots more converted

* Lots more converted & fixed a bunch of broken pipes due to inject()

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

* Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection

* Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

* Fixed a bug where character tag badges weren't showing the name on chapter info

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

* Migrated the localization strings for the backend into a new file.

* Updated the localization service to be able to do backend localization with fallback to english.

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

* Everything is done except options and basic shakeout proves response times are good. Unit tests are broken.

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

* I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase.

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: expertjun <jtrobin@naver.com>
Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
Joe Milazzo 2023-08-03 10:33:51 -05:00 committed by GitHub
parent 670bf82c38
commit 3b23d63234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
389 changed files with 13652 additions and 7925 deletions

View file

@ -1,44 +1,45 @@
<ng-container *transloco="let t; read: 'bulk-add-to-collection'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Collection</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
<div class="mb-3" *ngIf="lists.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
<div class="mb-3" *ngIf="lists.length >= 5">
<label for="filter" class="form-label">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<ul class="list-group">
</div>
<ul class="list-group">
<li class="list-group-item clickable" tabindex="0" role="option" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" title="Promoted"></i>
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" [title]="t('promoted')"></i>
</li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">{{t('no-data')}}</li>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</li>
</ul>
</div>
<div class="modal-footer" style="justify-content: normal">
<div style="width: 100%;">
<div class="d-flex">
<div class="col-9 col-lg-10">
<label class="form-label visually-hidden" for="add-rlist">Collection</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div>
<div class="col-2">
<button type="submit" class="btn btn-primary" (click)="create()">Create</button>
</div>
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</li>
</ul>
</div>
</div>
</form>
<div class="modal-footer" style="justify-content: normal">
<div style="width: 100%;">
<div class="d-flex">
<div class="col-9 col-lg-10">
<label class="form-label visually-hidden" for="add-rlist">{{t('collection-label')}}</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div>
<div class="col-2">
<button type="submit" class="btn btn-primary" (click)="create()">{{t('create')}}</button>
</div>
</div>
</div>
</div>
</form>
</ng-container>

View file

@ -1,4 +1,15 @@
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
inject,
Input,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
@ -7,11 +18,12 @@ import { ReadingList } from 'src/app/_models/reading-list';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import {CommonModule} from "@angular/common";
import {FilterPipe} from "../../../pipe/filter.pipe";
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
@Component({
selector: 'app-bulk-add-to-collection',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FilterPipe, NgbModalModule],
imports: [CommonModule, ReactiveFormsModule, FilterPipe, NgbModalModule, TranslocoModule],
templateUrl: './bulk-add-to-collection.component.html',
styleUrls: ['./bulk-add-to-collection.component.scss'],
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
@ -34,6 +46,8 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
translocoService = inject(TranslocoService);
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
@ -69,7 +83,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
create() {
const tagName = this.listForm.value.title;
this.collectionService.addByMultiple(0, this.seriesIds, tagName).subscribe(() => {
this.toastr.success('Series added to ' + tagName + ' collection');
this.toastr.success(this.translocoService.translate('toasts.series-added-to-collection', {collectionName: tagName}));
this.modal.close();
});
}
@ -78,7 +92,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
if (this.seriesIds.length === 0) return;
this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => {
this.toastr.success('Series added to ' + tag.title + ' collection');
this.toastr.success(this.translocoService.translate('toasts.series-added-to-collection', {collectionName: tag.title}));
this.modal.close();
});

View file

@ -1,95 +1,98 @@
<ng-container *transloco="let t; read: 'edit-collection-tags'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag.title}} Collection</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title', {collectionName: tag.title})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{TabID.General}}</a>
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{t(TabID.General)}}</a>
<ng-template ngbNavContent>
<form [formGroup]="collectionTagForm">
<div class="row g-0 mb-3">
<div class="col-md-8 col-sm-12">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="title" type="text"
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
This field is required
</div>
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
Name must be unique
</div>
</div>
</div>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">Promote</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
<ng-template #promotedTooltip>Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.</ng-template>
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
</div>
</div>
<form [formGroup]="collectionTagForm">
<div class="row g-0 mb-3">
<div class="col-md-8 col-sm-12">
<label for="library-name" class="form-label">{{t('name-label')}}</label>
<input id="library-name" class="form-control" formControlName="title" type="text"
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
{{t('required-field')}}
</div>
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
{{t('name-validation')}}
</div>
</div>
<div class="row g-0 mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">{{t('promote-label')}}</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
<ng-template #promotedTooltip>{{t('promote-tooltip')}}</ng-template>
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
</div>
</div>
</div>
</form>
<div class="row g-0 mb-3">
<label for="summary" class="form-label">{{t('summary-label')}}</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</ng-template>
</li>
</li>
<li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{TabID.Series}}</a>
<ng-template ngbNavContent>
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let item of series; let i = index">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
</ul>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
<li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{t(TabID.Series)}}</a>
<ng-template ngbNavContent>
<div class="list-group" *ngIf="!isLoading">
<h6>{{t('series-title')}}</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let item of series; let i = index">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</ng-template>
</li>
</li>
</ul>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(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>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{TabID.CoverImage}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)"
(selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked"
(resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)"
(selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked"
(resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" [disabled]="collectionTagForm.invalid" (click)="save()">Save</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
<button type="button" class="btn btn-primary" [disabled]="collectionTagForm.invalid" (click)="save()">{{t('save')}}</button>
</div>
</ng-container>

View file

@ -34,18 +34,19 @@ import { UploadService } from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
enum TabID {
General = 'General',
CoverImage = 'Cover Image',
Series = 'Series'
General = 'general-tab',
CoverImage = 'cover-image-tab',
Series = 'series-tab'
}
@Component({
selector: 'app-edit-collection-tags',
standalone: true,
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip],
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoModule],
templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -65,6 +66,7 @@ export class EditCollectionTagsComponent implements OnInit {
imageUrls: Array<string> = [];
selectedCover: string = '';
private readonly destroyRef = inject(DestroyRef);
translocoService = inject(TranslocoService);
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
@ -80,7 +82,7 @@ export class EditCollectionTagsComponent implements OnInit {
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
private confirmService: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService,
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
@ -170,7 +172,8 @@ export class EditCollectionTagsComponent implements OnInit {
const tag = this.collectionTagForm.value;
tag.id = this.tag.id;
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
if (unselectedIds.length == this.series.length &&
!await this.confirmService.confirm(this.translocoService.translate('toasts.no-series-collection-warning'))) {
return;
}
@ -185,7 +188,7 @@ export class EditCollectionTagsComponent implements OnInit {
forkJoin(apis).subscribe(() => {
this.modal.close({success: true, coverImageUpdated: selectedIndex > 0});
this.toastr.success('Tag updated');
this.toastr.success(this.translocoService.translate('toasts.collection-updated'));
});
}

View file

@ -1,492 +1,493 @@
<div class="modal-container" *ngIf="series !== undefined">
<ng-container *transloco="let t; read: 'edit-series-modal'">
<div class="modal-container" *ngIf="series !== undefined">
<div class="modal-header">
<h4 class="modal-title">
{{this.series.name}} Details</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
<h4 class="modal-title">
{{t('title', {seriesName: this.series.name})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>{{tabs[TabID.General]}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="name" class="form-label">Name</label>
<div class="input-group">
<input id="name" class="form-control" formControlName="name" type="text" readonly
[class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
<div class="invalid-feedback" *ngIf="errors.required">
This field is required
</div>
</ng-container>
</div>
</div>
</div>
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>{{t(tabs[TabID.General])}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="name" class="form-label">{{t('name-label')}}</label>
<div class="input-group">
<input id="name" class="form-control" formControlName="name" type="text" readonly
[class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
<div class="invalid-feedback" *ngIf="errors.required">
{{t('required-field')}}
</div>
</ng-container>
</div>
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="sort-name" class="form-label">Sort Name</label>
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
<div class="invalid-feedback" *ngIf="errors.required">
This field is required
</div>
</ng-container>
</div>
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="sort-name" class="form-label">{{t('sort-name-label')}}</label>
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
<div class="invalid-feedback" *ngIf="errors.required">
{{t('required-field')}}
</div>
</ng-container>
</div>
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="localized-name" class="form-label">Localized Name</label>
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="localized-name" class="form-label">{{t('localized-name-label')}}</label>
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
</div>
<div class="row g-0" *ngIf="metadata">
<div class="mb-3" style="width: 100%">
<label for="summary" class="form-label">Summary</label>
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</div>
<div class="row g-0" *ngIf="metadata">
<div class="mb-3" style="width: 100%">
<label for="summary" class="form-label">{{t('summary-label')}}</label>
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</div>
</ng-template>
</li>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
<a ngbNavLink>{{tabs[TabID.Metadata]}}</a>
<ng-template ngbNavContent>
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
<a ngbNavLink>{{t(tabs[TabID.Metadata])}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3">
<label for="collections" class="form-label">Collections </label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="mb-3" style="width: 100%">
<label for="release-year" class="form-label">Release Year</label>
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.pattern">
This must be a valid year greater than 1000 and 4 characters long
</p>
</ng-container>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="genres" class="form-label">Genres</label>
<app-typeahead (selectedData)="updateGenres($event)" [settings]="genreSettings"
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
(newItemAdded)="metadata.genresLocked = true" (selectedData)="metadata.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<app-typeahead (selectedData)="updateTags($event)" [settings]="tagsSettings"
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
(newItemAdded)="metadata.tagsLocked = true" (selectedData)="metadata.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="language" class="form-label">Language</label>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true" (selectedData)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="age-rating" class="form-label">Age Rating</label>
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'ageRatingLocked' }"></ng-container>
<select class="form-select"id="age-rating" formControlName="ageRating">
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="mb-3">
<label for="publication-status" class="form-label">Publication Status</label>
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
<select class="form-select"id="publication-status" formControlName="publicationStatus">
<option *ngFor="let opt of publicationStatuses" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.People]">
<a ngbNavLink>{{tabs[TabID.People]}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3">
<label for="writer" class="form-label">Writer</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="metadata.writersLocked" (onUnlock)="metadata.writersLocked = false"
(newItemAdded)="metadata.writersLocked = true" (selectedData)="metadata.writersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="cover-artist" class="form-label">Cover Artist</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="metadata.coverArtistsLocked" (onUnlock)="metadata.coverArtistsLocked = false"
(newItemAdded)="metadata.coverArtistsLocked = true" (selectedData)="metadata.coverArtistsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="publisher" class="form-label">Publisher</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="penciller" class="form-label">Penciller</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="metadata.pencillersLocked" (onUnlock)="metadata.pencillersLocked = false"
(newItemAdded)="metadata.pencillersLocked = true" (selectedData)="metadata.pencillersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="letterer" class="form-label">Letterer</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="metadata.letterersLocked" (onUnlock)="metadata.letterersLocked = false"
(newItemAdded)="metadata.letterersLocked = true" (selectedData)="metadata.letterersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="inker" class="form-label">Inker</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="metadata.inkersLocked" (onUnlock)="metadata.inkersLocked = false"
(newItemAdded)="metadata.inkersLocked = true" (selectedData)="metadata.inkersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="editor" class="form-label">Editor</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)"
[(locked)]="metadata.editorsLocked" (onUnlock)="metadata.editorsLocked = false"
(newItemAdded)="metadata.editorsLocked = true" (selectedData)="metadata.editorsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="colorist" class="form-label">Colorist</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="metadata.coloristsLocked" (onUnlock)="metadata.coloristsLocked = false"
(newItemAdded)="metadata.coloristsLocked = true" (selectedData)="metadata.coloristsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="character" class="form-label">Character</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="metadata.charactersLocked" (onUnlock)="metadata.charactersLocked = false"
(newItemAdded)="metadata.charactersLocked = true" (selectedData)="metadata.charactersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="translator" class="form-label">Translators</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorsLocked" (onUnlock)="metadata.translatorsLocked = false"
(newItemAdded)="metadata.translatorsLocked = true" (selectedData)="metadata.translatorsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.WebLinks]" *ngIf="metadata">
<a ngbNavLink>{{tabs[TabID.WebLinks]}}</a>
<ng-template ngbNavContent>
<p>Here you can add many different links to external services.</p>
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
<div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3">
<label for="web-link--{{i}}" class="visually-hidden">Web Link</label>
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
</div>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary me-1" (click)="addWebLink()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">Add Link</span>
</button>
<button class="btn btn-secondary" (click)="removeWebLink(i)">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">Remove Link</span>
</button>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.CoverImage]">
<a ngbNavLink>{{tabs[TabID.CoverImage]}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Related]">
<a ngbNavLink>{{tabs[TabID.Related]}}</a>
<ng-template ngbNavContent>
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Info]">
<a ngbNavLink>{{tabs[TabID.Info]}}</a>
<ng-template ngbNavContent>
<h4>Information</h4>
<div class="row g-0 mb-2">
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | sentenceCase}}</div>
<div class="col-md-6">Format: <app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge></div>
</div>
<div class="row g-0 mb-2">
<div class="col-md-6">Created: {{series.created | date:'shortDate'}}</div>
<div class="col-md-6">Last Read: {{series.latestReadDate | defaultDate | timeAgo}}</div>
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
<div class="row g-0">
<div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3">
<label for="collections" class="form-label">{{t('collections-label')}}</label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="mb-3" style="width: 100%">
<label for="release-year" class="form-label">{{t('release-year-label')}}</label>
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.pattern">
This must be a valid year greater than 1000 and 4 characters long
</p>
</ng-container>
</div>
</div>
</div>
</div>
<div class="row g-0 mb-2">
<div class="col-auto">Folder Path: {{series.folderPath | defaultValue}}</div>
</div>
<div class="row g-0 mb-2" *ngIf="metadata">
<div class="col-md-6">
Max Items: {{metadata.maxCount}}
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Highest Count found across all ComicInfo in the Series" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">
Total Items: {{metadata.totalCount}}
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Max Issue or Volume field from all ComicInfo in the series" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">Publication Status: {{metadata.publicationStatus | publicationStatus}}</div>
<div class="col-md-6">Total Pages: {{series.pages}}</div>
<div class="col-md-6">Size: {{size | bytes}}</div>
</div>
<h4>Volumes</h4>
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
<span class="invisible">Loading...</span>
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="d-flex my-4" *ngFor="let volume of seriesVolumes">
<app-image class="me-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
<div>
<div class="row g-0">
<div class="col">
Added: {{volume.created | date: 'short'}}
</div>
<div class="col">
Last Modified: {{volume.lastModified | date: 'short'}}
</div>
</div>
<div class="row g-0">
<div class="col">
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()"
[attr.aria-expanded]="!volumeCollapsed[volume.name]">
View Files
</button>
</div>
<div class="col">
Pages: {{volume.pages}}
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="genres" class="form-label">{{t('genres-label')}}</label>
<app-typeahead (selectedData)="updateGenres($event)" [settings]="genreSettings"
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
(newItemAdded)="metadata.genresLocked = true" (selectedData)="metadata.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
<ul class="list-group mt-2">
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
Chapter: {{file.chapter}}
</div>
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="tags" class="form-label">{{t('tags-label')}}</label>
<app-typeahead (selectedData)="updateTags($event)" [settings]="tagsSettings"
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
(newItemAdded)="metadata.tagsLocked = true" (selectedData)="metadata.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="language" class="form-label">{{t('language-label')}}</label>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true" (selectedData)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</label>
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'ageRatingLocked' }"></ng-container>
<select class="form-select"id="age-rating" formControlName="ageRating">
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="mb-3">
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</label>
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
<select class="form-select"id="publication-status" formControlName="publicationStatus">
<option *ngFor="let opt of publicationStatuses" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.People]">
<a ngbNavLink>{{t(tabs[TabID.People])}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3">
<label for="writer" class="form-label">{{t('writer-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="metadata.writersLocked" (onUnlock)="metadata.writersLocked = false"
(newItemAdded)="metadata.writersLocked = true" (selectedData)="metadata.writersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="cover-artist" class="form-label">{{t('cover-artist-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="metadata.coverArtistsLocked" (onUnlock)="metadata.coverArtistsLocked = false"
(newItemAdded)="metadata.coverArtistsLocked = true" (selectedData)="metadata.coverArtistsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="publisher" class="form-label">{{t('publisher-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="penciller" class="form-label">{{t('penciller-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="metadata.pencillersLocked" (onUnlock)="metadata.pencillersLocked = false"
(newItemAdded)="metadata.pencillersLocked = true" (selectedData)="metadata.pencillersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="letterer" class="form-label">{{t('letterer-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="metadata.letterersLocked" (onUnlock)="metadata.letterersLocked = false"
(newItemAdded)="metadata.letterersLocked = true" (selectedData)="metadata.letterersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="inker" class="form-label">{{t('inker-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="metadata.inkersLocked" (onUnlock)="metadata.inkersLocked = false"
(newItemAdded)="metadata.inkersLocked = true" (selectedData)="metadata.inkersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="editor" class="form-label">{{t('editor-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)"
[(locked)]="metadata.editorsLocked" (onUnlock)="metadata.editorsLocked = false"
(newItemAdded)="metadata.editorsLocked = true" (selectedData)="metadata.editorsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="colorist" class="form-label">{{t('colorist-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="metadata.coloristsLocked" (onUnlock)="metadata.coloristsLocked = false"
(newItemAdded)="metadata.coloristsLocked = true" (selectedData)="metadata.coloristsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="character" class="form-label">{{t('character-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="metadata.charactersLocked" (onUnlock)="metadata.charactersLocked = false"
(newItemAdded)="metadata.charactersLocked = true" (selectedData)="metadata.charactersLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="translator" class="form-label">{{t('translator-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorsLocked" (onUnlock)="metadata.translatorsLocked = false"
(newItemAdded)="metadata.translatorsLocked = true" (selectedData)="metadata.translatorsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.WebLinks]" *ngIf="metadata">
<a ngbNavLink>{{t(tabs[TabID.WebLinks])}}</a>
<ng-template ngbNavContent>
<p>{{t('web-link-description')}}</p>
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
<div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3">
<label for="web-link--{{i}}" class="visually-hidden">{{t('web-link-label')}}</label>
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
</div>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary me-1" (click)="addWebLink()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('add-link-alt')}}</span>
</button>
<button class="btn btn-secondary" (click)="removeWebLink(i)">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-link-alt')}}</span>
</button>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.CoverImage]">
<a ngbNavLink>{{t(tabs[TabID.CoverImage])}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
{{t('cover-image-description')}}
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Related]">
<a ngbNavLink>{{t(tabs[TabID.Related])}}</a>
<ng-template ngbNavContent>
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Info]">
<a ngbNavLink>{{t(tabs[TabID.Info])}}</a>
<ng-template ngbNavContent>
<h4>{{t('info-title')}}</h4>
<div class="row g-0 mb-2">
<div class="col-md-6" *ngIf="libraryName">{{t('library-title')}} {{libraryName | sentenceCase}}</div>
<div class="col-md-6">{{t('format-title')}} <app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge></div>
</div>
<div class="row g-0 mb-2">
<div class="col-md-6">{{t('created-title')}} {{series.created | date:'shortDate'}}</div>
<div class="col-md-6">{{t('last-read-title')}} {{series.latestReadDate | defaultDate | timeAgo}}</div>
<div class="col-md-6">{{t('last-added-title')}} {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
<div class="col-md-6">{{t('last-scanned-title')}} {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
</div>
<div class="row g-0 mb-2">
<div class="col-auto">{{t('folder-path-title')}} {{series.folderPath | defaultValue}}</div>
</div>
<div class="row g-0 mb-2" *ngIf="metadata">
<div class="col-md-6">
{{t('max-items-title')}} {{metadata.maxCount}}
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('highest-count-tooltip')" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">
{{t('total-items-title')}} {{metadata.totalCount}}
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('max-issue-tooltip')" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">{{t('publication-status-title')}} {{metadata.publicationStatus | publicationStatus}}</div>
<div class="col-md-6">{{t('total-pages-title')}} {{series.pages}}</div>
<div class="col-md-6">{{t('size-title')}} {{size | bytes}}</div>
</div>
<h4>Volumes</h4>
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="d-flex my-4" *ngFor="let volume of seriesVolumes">
<app-image class="me-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">{{t('volume-num')}} {{volume.name}}</h5>
<div>
<div class="row g-0">
<div class="col">
{{t('added-title')}} {{volume.created | date: 'short'}}
</div>
<div class="col">
{{t('last-modified-title')}} {{volume.lastModified | date: 'short'}}
</div>
</div>
<div class="row g-0">
<div class="col">
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()"
[attr.aria-expanded]="!volumeCollapsed[volume.name]">
{{t('view-files')}}
</button>
</div>
<div class="col">
{{t('pages-title')}} {{volume.pages}}
</div>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
<ul class="list-group mt-2">
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
{{t('chapter-title')}} {{file.chapter}}
</div>
<div class="col">
{{t('pages-title')}} {{file.pages}}
</div>
<div class="col">
{{t('format-title')}} <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
</div>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
</div>
</div>
</div>
</li>
</ul>
</form>
</ul>
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">Save</button>
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">{{t('save')}}</button>
</div>
</div>
</div>
<ng-template #lock let-item="item" let-field="field">
<ng-template #lock let-item="item" let-field="field">
<span class="input-group-text clickable" (click)="unlock(item, field)">
<i class="fa fa-lock" aria-hidden="true"></i>
<span class="visually-hidden">Field is locked</span>
<span class="visually-hidden">{{t('field-locked-alt')}}</span>
</span>
</ng-template>
</ng-template>
</ng-container>

View file

@ -14,7 +14,7 @@ import {
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavModule, NgbNavOutlet,
NgbNavOutlet,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Observable, of } from 'rxjs';
@ -38,7 +38,7 @@ import { MetadataService } from 'src/app/_services/metadata.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} from "@angular/common";
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {EditSeriesRelationComponent} from "../../edit-series-relation/edit-series-relation.component";
@ -51,6 +51,7 @@ import {PublicationStatusPipe} from "../../../pipe/publication-status.pipe";
import {BytesPipe} from "../../../pipe/bytes.pipe";
import {ImageComponent} from "../../../shared/image/image.component";
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
import {TranslocoModule} from "@ngneat/transloco";
enum TabID {
General = 0,
@ -87,6 +88,7 @@ enum TabID {
NgbCollapse,
NgbNavOutlet,
DefaultValuePipe,
TranslocoModule,
],
templateUrl: './edit-series-modal.component.html',
@ -104,7 +106,7 @@ export class EditSeriesModalComponent implements OnInit {
initSeries!: Series;
volumeCollapsed: any = {};
tabs = ['General', 'Metadata', 'People', 'Web Links', 'Cover Image', 'Related', 'Info'];
tabs = ['general-tab', 'metadata-tab', 'people-tab', 'web-links-tab', 'cover-image-tab', 'related-tab', 'info-tab'];
active = this.tabs[0];
editSeriesForm!: FormGroup;
libraryName: string | undefined = undefined;