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

@ -3,6 +3,7 @@
<h2 title>{{t('title')}}</h2>
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="collections"
@ -13,12 +14,21 @@
<ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions"
[imageUrl]="imageService.getCollectionCoverImage(item.id)"
(clicked)="loadCollection(item)"></app-card-item>
(clicked)="loadCollection(item)"
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true">
<ng-template #subtitle>
<app-collection-owner [collection]="item"></app-collection-owner>
</ng-template>
</app-card-item>
</ng-template>
<ng-template #noData>
{{t('no-data')}}
<ng-container *ngIf="isAdmin$ | async"> {{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a></ng-container>
@if(isAdmin$ | async) {
{{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
}
</ng-template>
</app-card-detail-layout>

View file

@ -4,6 +4,7 @@ import {
Component,
DestroyRef,
EventEmitter,
HostListener,
inject,
OnInit
} from '@angular/core';
@ -13,7 +14,7 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {map, of} from 'rxjs';
import {Observable} from 'rxjs/internal/Observable';
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import {CollectionTag} from 'src/app/_models/collection-tag';
import {UserCollection} from 'src/app/_models/collection-tag';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Tag} from 'src/app/_models/tag';
import {AccountService} from 'src/app/_services/account.service';
@ -22,14 +23,24 @@ import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {ImageService} from 'src/app/_services/image.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {AsyncPipe, DecimalPipe} from '@angular/common';
import {CardItemComponent} from '../../../cards/card-item/card-item.component';
import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {CollectionOwnerComponent} from "../collection-owner/collection-owner.component";
import {User} from "../../../_models/user";
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
import {ActionService} from "../../../_services/action.service";
import {KEY_CODES} from "../../../shared/_services/utility.service";
@Component({
@ -38,51 +49,87 @@ import {ToastrService} from "ngx-toastr";
styleUrls: ['./all-collections.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, NgIf, AsyncPipe, DecimalPipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, AsyncPipe, DecimalPipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, BulkOperationsComponent, SeriesCardComponent]
})
export class AllCollectionsComponent implements OnInit {
isLoading: boolean = true;
collections: CollectionTag[] = [];
collectionTagActions: ActionItem<CollectionTag>[] = [];
jumpbarKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: CollectionTag) => `${item.id}_${item.title}`;
isAdmin$: Observable<boolean> = of(false);
filterOpen: EventEmitter<boolean> = new EventEmitter();
private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService);
private readonly toastr = inject(ToastrService);
private readonly collectionService = inject(CollectionTagService);
private readonly router = inject(Router);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly modalService = inject(NgbModal);
private readonly titleService = inject(Title);
private readonly jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef);
public readonly imageService = inject(ImageService);
public readonly accountService = inject(AccountService);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly actionService = inject(ActionService);
constructor(private collectionService: CollectionTagService, private router: Router,
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
private titleService: Title, private jumpbarService: JumpbarService,
private readonly cdRef: ChangeDetectorRef, public imageService: ImageService,
public accountService: AccountService) {
protected readonly ScrobbleProvider = ScrobbleProvider;
isLoading: boolean = true;
collections: UserCollection[] = [];
collectionTagActions: ActionItem<UserCollection>[] = [];
jumpbarKeys: Array<JumpKey> = [];
isAdmin$: Observable<boolean> = of(false);
filterOpen: EventEmitter<boolean> = new EventEmitter();
trackByIdentity = (index: number, item: UserCollection) => `${item.id}_${item.title}`;
user!: User;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = true;
}
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = false;
}
}
constructor() {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title'));
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user) {
this.user = user;
this.cdRef.markForCheck();
}
});
}
ngOnInit() {
this.loadPage();
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
this.cdRef.markForCheck();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
.filter(action => this.collectionService.actionListFilter(action, user));
this.cdRef.markForCheck();
});
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(user => {
if (!user) return false;
return this.accountService.hasAdminRole(user);
}));
}
loadCollection(item: CollectionTag) {
loadCollection(item: UserCollection) {
this.router.navigate(['collections', item.id]);
this.loadPage();
}
loadPage() {
this.isLoading = true;
this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => {
this.collectionService.allCollections().subscribe(tags => {
this.collections = [...tags];
this.isLoading = false;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title);
@ -90,8 +137,20 @@ export class AllCollectionsComponent implements OnInit {
});
}
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
if (collectionTag.owner != this.user.username) {
this.toastr.error(translate('toasts.collection-not-owned'));
return;
}
switch (action.action) {
case Action.Promote:
this.collectionService.promoteMultipleCollections([collectionTag.id], true).subscribe();
break;
case Action.UnPromote:
this.collectionService.promoteMultipleCollections([collectionTag.id], false).subscribe();
break;
case(Action.Delete):
this.collectionService.deleteTag(collectionTag.id).subscribe(res => {
this.toastr.success(res);
@ -110,4 +169,33 @@ export class AllCollectionsComponent implements OnInit {
break;
}
}
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedCollectionIndexies = this.bulkSelectionService.getSelectedCardsForSource('collection');
const selectedCollections = this.collections.filter((col, index: number) => selectedCollectionIndexies.includes(index + ''));
switch (action.action) {
case Action.Promote:
this.actionService.promoteMultipleCollections(selectedCollections, true, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.UnPromote:
this.actionService.promoteMultipleCollections(selectedCollections, false, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.Delete:
this.actionService.deleteMultipleCollections(selectedCollections, (successful) => {
if (!successful) return;
this.loadPage();
this.bulkSelectionService.deselectAll();
});
break;
}
}
}

View file

@ -23,7 +23,7 @@ import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection
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 {CollectionTag} from 'src/app/_models/collection-tag';
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';
import {Pagination} from 'src/app/_models/pagination';
@ -52,6 +52,8 @@ import {CardActionablesComponent} from "../../../_single-module/card-actionables
import {FilterField} from "../../../_models/metadata/v2/filter-field";
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";
@Component({
selector: 'app-collection-detail',
@ -63,20 +65,41 @@ import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
})
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService);
private readonly collectionService = inject(CollectionTagService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly seriesService = inject(SeriesService);
private readonly toastr = inject(ToastrService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
private readonly titleService = inject(Title);
private readonly jumpbarService = inject(JumpbarService);
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService);
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
destroyRef = inject(DestroyRef);
translocoService = inject(TranslocoService);
collectionTag!: CollectionTag;
collectionTag!: UserCollection;
isLoading: boolean = true;
series: Array<Series> = [];
pagination: Pagination = new Pagination();
collectionTagActions: ActionItem<CollectionTag>[] = [];
collectionTagActions: ActionItem<UserCollection>[] = [];
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
summary: string = '';
user!: User;
actionInProgress: boolean = false;
filterActiveCheck!: SeriesFilterV2;
@ -153,12 +176,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
}
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title, private jumpbarService: JumpbarService,
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
constructor(@Inject(DOCUMENT) private document: Document) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id');
@ -184,7 +203,14 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
ngOnInit(): void {
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (!user) return;
this.user = user;
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
.filter(action => this.collectionService.actionListFilter(action, user));
this.cdRef.markForCheck();
});
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => {
if (event.event == EVENTS.SeriesAddedToCollection) {
@ -217,11 +243,10 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
updateTag(tagId: number) {
this.collectionService.allTags().subscribe(tags => {
this.collectionService.allCollections().subscribe(tags => {
const matchingTags = tags.filter(t => t.id === tagId);
if (matchingTags.length === 0) {
this.toastr.error(this.translocoService.translate('errors.collection-invalid-access'));
// TODO: Why would access need to be checked? Even if a id was guessed, the series wouldn't return
this.router.navigateByUrl('/');
return;
}
@ -261,8 +286,18 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
});
}
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
if (collectionTag.owner != this.user.username) {
this.toastr.error(translate('toasts.collection-not-owned'));
return;
}
switch (action.action) {
case Action.Promote:
this.collectionService.promoteMultipleCollections([this.collectionTag.id], true).subscribe();
break;
case Action.UnPromote:
this.collectionService.promoteMultipleCollections([this.collectionTag.id], false).subscribe();
break;
case(Action.Edit):
this.openEditCollectionTagModal(this.collectionTag);
break;
@ -283,7 +318,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
}
openEditCollectionTagModal(collectionTag: CollectionTag) {
openEditCollectionTagModal(collectionTag: UserCollection) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = this.collectionTag;
modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
@ -291,6 +326,4 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.loadPage();
});
}
protected readonly undefined = undefined;
}

View file

@ -0,0 +1,14 @@
<ng-container *transloco="let t; read: 'collection-owner'">
@if (accountService.currentUser$ | async; as user) {
<div class="fw-light text-accent">
{{t('collection-created-label', {owner: collection.owner})}}
@if(collection.source !== ScrobbleProvider.Kavita) {
{{t('collection-via-label')}}
<app-image [imageUrl]="collection.source | providerImage"
width="16px" height="16px"
[ngbTooltip]="collection.source | providerName"
[attr.aria-label]="collection.source | providerName"></app-image>
}
</div>
}
</ng-container>

View file

@ -0,0 +1,4 @@
.text-accent {
font-size: small;
color: var(---accent-text-color);
}

View file

@ -0,0 +1,35 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {UserCollection} from "../../../_models/collection-tag";
import {TranslocoDirective} from "@ngneat/transloco";
import {AsyncPipe, JsonPipe} from "@angular/common";
import {AccountService} from "../../../_services/account.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-collection-owner',
standalone: true,
imports: [
ProviderImagePipe,
ProviderNamePipe,
TranslocoDirective,
AsyncPipe,
JsonPipe,
ImageComponent,
NgbTooltip
],
templateUrl: './collection-owner.component.html',
styleUrl: './collection-owner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionOwnerComponent {
protected readonly accountService = inject(AccountService);
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) collection!: UserCollection;
}