Collection Rework (#2830)

This commit is contained in:
Joe Milazzo 2024-04-06 12:03:49 -05:00 committed by GitHub
parent 0dacc061f1
commit deaaccb96a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 5413 additions and 1120 deletions

View file

@ -6,23 +6,37 @@
</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>
</div>
</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]="t('promoted')"></i>
</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">{{t('loading')}}</span>
@if (lists.length >= 5) {
<div class="mb-3">
<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>
</div>
</li>
</div>
}
<ul class="list-group">
@for(collectionTag of lists | filter: filterList; let i = $index; track collectionTag.title) {
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToCollection(collectionTag)">
{{collectionTag.title}}
@if (collectionTag.promoted) {
<i class="fa fa-angle-double-up" [title]="t('promoted')"></i>
}
</li>
}
@if (lists.length === 0 && !loading) {
<li class="list-group-item">{{t('no-data')}}</li>
}
@if (loading) {
<li class="list-group-item">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</li>
}
</ul>
</div>
<div class="modal-footer" style="justify-content: normal">

View file

@ -13,7 +13,7 @@ import {
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { CollectionTag } from 'src/app/_models/collection-tag';
import {UserCollection} from 'src/app/_models/collection-tag';
import { ReadingList } from 'src/app/_models/reading-list';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import {CommonModule} from "@angular/common";
@ -31,28 +31,25 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco
})
export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
private readonly modal = inject(NgbActiveModal);
private readonly collectionService = inject(CollectionTagService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) title!: string;
/**
* Series Ids to add to Collection Tag
*/
@Input() seriesIds: Array<number> = [];
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
/**
* All existing collections sorted by recent use date
*/
lists: Array<CollectionTag> = [];
lists: Array<UserCollection> = [];
loading: boolean = false;
listForm: FormGroup = new FormGroup({});
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.listForm.addControl('title', new FormControl(this.title, []));
@ -60,7 +57,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
this.loading = true;
this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => {
this.collectionService.allCollections(true).subscribe(tags => {
this.lists = tags;
this.loading = false;
this.cdRef.markForCheck();
@ -87,7 +84,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
});
}
addToCollection(tag: CollectionTag) {
addToCollection(tag: UserCollection) {
if (this.seriesIds.length === 0) return;
this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => {

View file

@ -16,14 +16,16 @@
<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')}}
@if (collectionTagForm.dirty || collectionTagForm.touched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (collectionTagForm.get('title')?.errors?.required) {
<div>{{t('required-field')}}</div>
}
@if (collectionTagForm.get('title')?.errors?.duplicateName) {
<div>{{t('name-validation')}}</div>
}
</div>
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
{{t('name-validation')}}
</div>
</div>
}
</div>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
@ -49,32 +51,46 @@
<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>
@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>
</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>
</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>
@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>
</div>
}
</ng-template>
</li>

View file

@ -1,12 +1,4 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {
NgbActiveModal,
@ -15,25 +7,30 @@ import {
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbPagination, NgbTooltip
NgbPagination,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { debounceTime, distinctUntilChanged, forkJoin, switchMap, tap } from 'rxjs';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
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 {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import {UserCollection} from 'src/app/_models/collection-tag';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {ImageService} from 'src/app/_services/image.service';
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} from "@angular/common";
import {CommonModule, NgTemplateOutlet} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
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";
enum TabID {
@ -45,14 +42,33 @@ enum TabID {
@Component({
selector: 'app-edit-collection-tags',
standalone: true,
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective],
imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe],
templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditCollectionTagsComponent implements OnInit {
@Input({required: true}) tag!: CollectionTag;
public readonly modal = inject(NgbActiveModal);
public readonly utilityService = inject(UtilityService);
private readonly destroyRef = inject(DestroyRef);
private readonly seriesService = inject(SeriesService);
private readonly collectionService = inject(CollectionTagService);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly libraryService = inject(LibraryService);
private readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) tag!: UserCollection;
series: Array<Series> = [];
selections!: SelectionModel<Series>;
isLoading: boolean = true;
@ -64,25 +80,18 @@ export class EditCollectionTagsComponent implements OnInit {
active = TabID.General;
imageUrls: Array<string> = [];
selectedCover: string = '';
private readonly destroyRef = inject(DestroyRef);
formGroup = new FormGroup({'filter': new FormControl('', [])});
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
get Breakpoint() {
return Breakpoint;
filterList = (listItem: Series) => {
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(query) >= 0 || listItem.localizedName.toLowerCase().indexOf(query) >= 0;
}
get TabID() {
return TabID;
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService,
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
if (this.pagination == undefined) {
@ -96,6 +105,20 @@ export class EditCollectionTagsComponent implements OnInit {
promoted: new FormControl(this.tag.promoted, { nonNullable: true, validators: [] }),
});
if (this.tag.source !== ScrobbleProvider.Kavita) {
this.collectionTagForm.get('title')?.disable();
this.collectionTagForm.get('summary')?.disable();
}
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
if (!this.accountService.hasPromoteRole(user)) {
this.collectionTagForm.get('promoted')?.disable();
this.cdRef.markForCheck();
}
});
this.collectionTagForm.get('title')?.valueChanges.pipe(
debounceTime(100),
distinctUntilChanged(),
@ -169,6 +192,9 @@ export class EditCollectionTagsComponent implements OnInit {
const unselectedIds = this.selections.unselected().map(s => s.id);
const tag = this.collectionTagForm.value;
tag.id = this.tag.id;
tag.title = this.collectionTagForm.get('title')!.value;
tag.summary = this.collectionTagForm.get('summary')!.value;
if (unselectedIds.length == this.series.length &&
!await this.confirmService.confirm(translate('toasts.no-series-collection-warning'))) {
@ -177,9 +203,13 @@ export class EditCollectionTagsComponent implements OnInit {
const apis = [
this.collectionService.updateTag(tag),
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
];
const unselectedSeries = this.selections.unselected().map(s => s.id);
if (unselectedSeries.length > 0) {
apis.push(this.collectionService.updateSeriesForTag(tag, unselectedSeries));
}
if (selectedIndex > 0) {
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover));
}
@ -207,5 +237,4 @@ export class EditCollectionTagsComponent implements OnInit {
});
this.cdRef.markForCheck();
}
}

View file

@ -72,13 +72,15 @@
<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">
<label for="language" class="form-label">{{t('language-label')}}</label>
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="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.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</div>
@ -138,22 +140,10 @@
</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);metadata.languageLocked = true;" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="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="col-lg-4 col-md-12 pe-2">-->
<!-- -->
<!-- </div>-->
<div class="col-lg-6 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' : ''}}">
@ -164,7 +154,7 @@
</div>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="col-lg-6 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' : ''}}">

View file

@ -22,7 +22,6 @@ import { map } from 'rxjs/operators';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Genre } from 'src/app/_models/metadata/genre';
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
import { Language } from 'src/app/_models/metadata/language';
@ -31,7 +30,6 @@ import { Person, PersonRole } from 'src/app/_models/metadata/person';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata';
import { Tag } from 'src/app/_models/tag';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service';
@ -119,7 +117,6 @@ export class EditSeriesModalComponent implements OnInit {
private readonly fb = inject(FormBuilder);
public readonly imageService = inject(ImageService);
private readonly libraryService = inject(LibraryService);
private readonly collectionService = inject(CollectionTagService);
private readonly uploadService = inject(UploadService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
@ -155,10 +152,8 @@ export class EditSeriesModalComponent implements OnInit {
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
collectionTagSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
collectionTags: CollectionTag[] = [];
tags: Tag[] = [];
genres: Genre[] = [];
ageRatings: Array<AgeRatingDto> = [];
@ -330,44 +325,15 @@ export class EditSeriesModalComponent implements OnInit {
setupTypeaheads() {
forkJoin([
this.setupCollectionTagsSettings(),
this.setupTagSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
this.setupLanguageTypeahead()
]).subscribe(results => {
this.collectionTags = this.metadata.collectionTags;
this.cdRef.markForCheck();
});
}
setupCollectionTagsSettings() {
this.collectionTagSettings.minCharacters = 0;
this.collectionTagSettings.multiple = true;
this.collectionTagSettings.id = 'collections';
this.collectionTagSettings.unique = true;
this.collectionTagSettings.addIfNonExisting = true;
this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter)));
this.collectionTagSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
});
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.title === b.title;
}
if (this.metadata.collectionTags) {
this.collectionTagSettings.savedData = this.metadata.collectionTags;
}
return of(true);
}
setupTagSettings() {
this.tagsSettings.minCharacters = 0;
this.tagsSettings.multiple = true;
@ -545,10 +511,6 @@ export class EditSeriesModalComponent implements OnInit {
});
}
fetchCollectionTags(filter: string = '') {
return this.collectionService.search(filter);
}
updateWeblinks(items: Array<string>) {
this.metadata.webLinks = items.map(s => s.replaceAll(',', '%2C')).join(',');
}
@ -559,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
const apis = [
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
this.seriesService.updateMetadata(this.metadata)
];
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
@ -585,10 +547,6 @@ export class EditSeriesModalComponent implements OnInit {
});
}
updateCollections(tags: CollectionTag[]) {
this.collectionTags = tags;
this.cdRef.markForCheck();
}
updateTags(tags: Tag[]) {
this.tags = tags;