Bulk Actions for Reading Lists (#3035)

This commit is contained in:
Joe Milazzo 2024-07-02 19:00:23 -05:00 committed by GitHub
parent 1918c9305e
commit 6434ed7c9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 965 additions and 16 deletions

View file

@ -550,7 +550,7 @@ export class ActionFactoryService {
}
],
},
// RBS will handle rendering this, so non-admins with download are appicable
// RBS will handle rendering this, so non-admins with download are applicable
{
action: Action.Download,
title: 'download',
@ -583,6 +583,20 @@ export class ActionFactoryService {
class: 'danger',
children: [],
},
{
action: Action.Promote,
title: 'promote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.UnPromote,
title: 'unpromote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
];
this.bookmarkActions = [

View file

@ -24,6 +24,7 @@ import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {FilterService} from "./filter.service";
import {ReadingListService} from "./reading-list.service";
export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
@ -48,7 +49,8 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { }
private readonly collectionTagService: CollectionTagService, private filterService: FilterService,
private readonly readingListService: ReadingListService) { }
ngOnDestroy() {
this.onDestroy.next();
@ -386,7 +388,7 @@ export class ActionService implements OnDestroy {
}
/**
* Mark all series as Unread.
* Mark all collections as promoted/unpromoted.
* @param collections UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
@ -422,6 +424,43 @@ export class ActionService implements OnDestroy {
});
}
/**
* Mark all reading lists as promoted/unpromoted.
* @param readingLists UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
*/
promoteMultipleReadingLists(readingLists: Array<ReadingList>, promoted: boolean, callback?: BooleanActionCallback) {
this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => {
if (promoted) {
this.toastr.success(translate('toasts.reading-list-promoted'));
} else {
this.toastr.success(translate('toasts.reading-list-unpromoted'));
}
if (callback) {
callback(true);
}
});
}
/**
* Deletes multiple collections
* @param readingLists ReadingList, should have id
* @param callback Optional callback to perform actions after API completes
*/
async deleteMultipleReadingLists(readingLists: Array<ReadingList>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => {
this.toastr.success(translate('toasts.reading-lists-deleted'));
if (callback) {
callback(true);
}
});
}
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });

View file

@ -8,7 +8,7 @@ import { PaginatedResult } from '../_models/pagination';
import { ReadingList, ReadingListItem } from '../_models/reading-list';
import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary';
import { TextResonse } from '../_types/text-response';
import { ActionItem } from './action-factory.service';
import {Action, ActionItem} from './action-factory.service';
@Injectable({
providedIn: 'root'
@ -87,9 +87,15 @@ export class ReadingListService {
return this.httpClient.post<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse);
}
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
if (readingList?.promoted && !isAdmin) return false;
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, canPromote: boolean) {
const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;
if (isPromotionAction) return canPromote;
return true;
// if (readingList?.promoted && !isAdmin) return false;
// return true;
}
nameExists(name: string) {
@ -107,4 +113,14 @@ export class ReadingListService {
getCharacters(readingListId: number) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
}
promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) {
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
}
deleteMultipleReadingLists(listIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse);
}
}

View file

@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs';
import {filter} from 'rxjs/operators';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList';
/**
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
@ -159,6 +159,10 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
if (Object.keys(this.selectedCards).filter(item => item === 'readingList').length > 0) {
return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
}

View file

@ -4,6 +4,7 @@
<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"

View file

@ -4,8 +4,12 @@
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle class="subtitle-with-actionables" *ngIf="pagination">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
@if (pagination) {
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
}
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="loadingLists"
@ -18,7 +22,9 @@
<ng-template #cardItem let-item let-position="idx" >
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
(clicked)="handleClick(item)"></app-card-item>
(clicked)="handleClick(item)"
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"></app-card-item>
</ng-template>
<ng-template #noData>

View file

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, inject, OnInit} from '@angular/core';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
@ -21,6 +21,12 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {Title} from "@angular/platform-browser";
import {WikiLink} from "../../../_models/wiki";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
import {KEY_CODES} from "../../../shared/_services/utility.service";
import {UserCollection} from "../../../_models/collection-tag";
import {User} from "../../../_models/user";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-reading-lists',
@ -28,30 +34,51 @@ import {WikiLink} from "../../../_models/wiki";
styleUrls: ['./reading-lists.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent]
})
export class ReadingListsComponent implements OnInit {
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly actionService = inject(ActionService);
protected readonly WikiLink = WikiLink;
lists: ReadingList[] = [];
loadingLists = false;
pagination!: Pagination;
isAdmin: boolean = false;
hasPromote: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
actions: {[key: number]: Array<ActionItem<ReadingList>>} = {};
globalActions: Array<ActionItem<any>> = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
trackByIdentity = (index: number, item: ReadingList) => `${item.id}_${item.title}`;
translocoService = inject(TranslocoService);
@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(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService,
private accountService: AccountService, private toastr: ToastrService, private router: Router,
private jumpbarService: JumpbarService, private readonly cdRef: ChangeDetectorRef, private ngbModal: NgbModal, private titleService: Title) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasPromote = this.accountService.hasPromoteRole(user);
this.cdRef.markForCheck();
this.loadPage();
this.titleService.setTitle('Kavita - ' + translate('side-nav.reading-lists'));
}
@ -59,8 +86,10 @@ export class ReadingListsComponent implements OnInit {
}
getActions(readingList: ReadingList) {
const d = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote));
return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote));
}
performAction(action: ActionItem<ReadingList>, readingList: ReadingList) {
@ -85,7 +114,7 @@ export class ReadingListsComponent implements OnInit {
switch(action.action) {
case Action.Delete:
this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted'));
this.toastr.success(translate('toasts.reading-list-deleted'));
this.loadPage();
});
break;
@ -126,4 +155,33 @@ export class ReadingListsComponent implements OnInit {
handleClick(list: ReadingList) {
this.router.navigateByUrl('lists/' + list.id);
}
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedReadingListIndexies = this.bulkSelectionService.getSelectedCardsForSource('readingList');
const selectedReadingLists = this.lists.filter((col, index: number) => selectedReadingListIndexies.includes(index + ''));
switch (action.action) {
case Action.Promote:
this.actionService.promoteMultipleReadingLists(selectedReadingLists, true, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.UnPromote:
this.actionService.promoteMultipleReadingLists(selectedReadingLists, false, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.Delete:
this.actionService.deleteMultipleReadingLists(selectedReadingLists, (successful) => {
if (!successful) return;
this.loadPage();
this.bulkSelectionService.deselectAll();
});
break;
}
}
}

View file

@ -2134,8 +2134,10 @@
"no-series-collection-warning": "Warning! No series are selected, saving will delete the Collection. Are you sure you want to continue?",
"collection-updated": "Collection updated",
"reading-list-deleted": "Reading list deleted",
"reading-lists-deleted": "Reading lists deleted",
"reading-list-updated": "Reading list updated",
"confirm-delete-reading-list": "Are you sure you want to delete the reading list? This cannot be undone.",
"confirm-delete-reading-lists": "Are you sure you want to delete the reading lists? This cannot be undone.",
"item-removed": "Item removed",
"nothing-to-remove": "Nothing to remove",
"series-added-to-reading-list": "Series added to reading list",
@ -2209,6 +2211,8 @@
"collection-not-owned": "You do not own this collection",
"collections-promoted": "Collections promoted",
"collections-unpromoted": "Collections un-promoted",
"reading-lists-promoted": "Reading Lists promoted",
"reading-lists-unpromoted": "Reading Lists un-promoted",
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode",