mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-02-12 08:18:58 -06:00
Add support for updating read status of multiple books at once
This commit is contained in:
committed by
Aditya Chandel
parent
77d68108ab
commit
c0e993c37a
@@ -119,10 +119,9 @@ public class BookController {
|
||||
return ResponseEntity.ok(bookRecommendationService.getRecommendations(id, limit));
|
||||
}
|
||||
|
||||
@PutMapping("/{bookId}/read-status")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<Void> updateReadStatus(@PathVariable long bookId, @RequestBody @Valid ReadStatusUpdateRequest request) {
|
||||
bookService.updateReadStatus(bookId, request.status());
|
||||
@PutMapping("/read-status")
|
||||
public ResponseEntity<Void> updateReadStatus(@RequestBody @Valid ReadStatusUpdateRequest request) {
|
||||
bookService.updateReadStatus(request.ids(), request.status());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.List;
|
||||
|
||||
public record ReadStatusUpdateRequest(@NotBlank String status) {}
|
||||
public record ReadStatusUpdateRequest(List<Long> ids, String status) {
|
||||
}
|
||||
|
||||
@@ -374,15 +374,26 @@ public class BookService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateReadStatus(Long bookId, String status) {
|
||||
BookEntity book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
public void updateReadStatus(List<Long> bookIds, String status) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
UserBookProgressEntity userBookProgress = userBookProgressRepository.findByUserIdAndBookId(user.getId(), book.getId()).orElse(new UserBookProgressEntity());
|
||||
userBookProgress.setUser(userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found")));
|
||||
userBookProgress.setBook(book);
|
||||
ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status);
|
||||
userBookProgress.setReadStatus(readStatus);
|
||||
userBookProgressRepository.save(userBookProgress);
|
||||
|
||||
List<BookEntity> books = bookRepository.findAllById(bookIds);
|
||||
if (books.size() != bookIds.size()) {
|
||||
throw ApiError.BOOK_NOT_FOUND.createException("One or more books not found");
|
||||
}
|
||||
|
||||
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
for (BookEntity book : books) {
|
||||
UserBookProgressEntity progress = userBookProgressRepository
|
||||
.findByUserIdAndBookId(user.getId(), book.getId())
|
||||
.orElse(new UserBookProgressEntity());
|
||||
|
||||
progress.setUser(userEntity);
|
||||
progress.setBook(book);
|
||||
progress.setReadStatus(readStatus);
|
||||
userBookProgressRepository.save(progress);
|
||||
}
|
||||
}
|
||||
|
||||
public ResponseEntity<Resource> downloadBook(Long bookId) {
|
||||
|
||||
@@ -343,15 +343,17 @@
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
<p-menu #menuMore [model]="moreActionsMenuItems" [popup]="true" appendTo="body" class="hidden"/>
|
||||
<p-button
|
||||
(click)="menuMore.toggle($event)"
|
||||
pTooltip="More actions"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
icon="pi pi-ellipsis-v">
|
||||
</p-button>
|
||||
<div class="card flex justify-center">
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="More actions"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
icon="pi pi-ellipsis-v">
|
||||
</p-button>
|
||||
<p-tieredMenu #menu [model]="tieredMenuItems" [popup]="true" appendTo="body"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<p-divider layout="vertical"></p-divider>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {Shelf} from '../../model/shelf.model';
|
||||
import {SortService} from '../../service/sort.service';
|
||||
import {SortDirection, SortOption} from '../../model/sort.model';
|
||||
import {BookState} from '../../model/state/book-state.model';
|
||||
import {Book} from '../../model/book.model';
|
||||
import {Book, ReadStatus} from '../../model/book.model';
|
||||
import {LibraryShelfMenuService} from '../../service/library-shelf-menu.service';
|
||||
import {BookTableComponent} from './book-table/book-table.component';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
@@ -24,7 +24,7 @@ import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {Menu} from 'primeng/menu';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {BookFilterComponent} from './book-filter/book-filter.component';
|
||||
import {BookFilterComponent, readStatusLabels} from './book-filter/book-filter.component';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {EntityViewPreferences, UserService} from '../../../settings/user-management/user.service';
|
||||
import {OverlayPanelModule} from 'primeng/overlaypanel';
|
||||
@@ -43,6 +43,8 @@ import {FilterSortPreferenceService} from './filters/filter-sorting-preferences.
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {MultiSelect} from 'primeng/multiselect';
|
||||
import {TableColumnPreferenceService} from './table-column-preference-service';
|
||||
import {TieredMenu} from 'primeng/tieredmenu';
|
||||
import {BookMenuService} from '../../service/book-menu.service';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -75,7 +77,7 @@ const SORT_DIRECTION = {
|
||||
standalone: true,
|
||||
templateUrl: './book-browser.component.html',
|
||||
styleUrls: ['./book-browser.component.scss'],
|
||||
imports: [Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, Divider, MultiSelect],
|
||||
imports: [Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, Divider, MultiSelect, TieredMenu],
|
||||
providers: [SeriesCollapseFilter],
|
||||
animations: [
|
||||
trigger('slideInOut', [
|
||||
@@ -134,6 +136,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
private bookService = inject(BookService);
|
||||
private shelfService = inject(ShelfService);
|
||||
private dialogHelperService = inject(BookDialogHelperService);
|
||||
private bookMenuService = inject(BookMenuService);
|
||||
private sortService = inject(SortService);
|
||||
private router = inject(Router);
|
||||
private changeDetectorRef = inject(ChangeDetectorRef);
|
||||
@@ -149,10 +152,11 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
lastAppliedSort: SortOption | null = null;
|
||||
private settingFiltersFromUrl = false;
|
||||
protected metadataMenuItems: MenuItem[] | undefined;
|
||||
protected moreActionsMenuItems: MenuItem[] | undefined;
|
||||
protected tieredMenuItems: MenuItem[] | undefined;
|
||||
currentBooks: Book[] = [];
|
||||
lastSelectedIndex: number | null = null;
|
||||
showFilter: boolean = false;
|
||||
|
||||
|
||||
get currentCardSize() {
|
||||
return this.coverScalePreferenceService.currentCardSize;
|
||||
@@ -174,6 +178,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.coverScalePreferenceService.scaleChange$.pipe(debounceTime(1000)).subscribe();
|
||||
|
||||
this.bookService.loadBooks();
|
||||
@@ -210,45 +215,15 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
this.clearFilter();
|
||||
});
|
||||
|
||||
this.metadataMenuItems = [
|
||||
{
|
||||
label: 'Refresh Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => this.updateMetadata()
|
||||
},
|
||||
{
|
||||
label: 'Bulk Metadata Editor',
|
||||
icon: 'pi pi-table',
|
||||
command: () => this.bulkEditMetadata()
|
||||
},
|
||||
{
|
||||
label: 'Multi-Book Metadata Editor',
|
||||
icon: 'pi pi-clone',
|
||||
command: () => this.multiBookEditMetadata()
|
||||
}
|
||||
];
|
||||
this.metadataMenuItems = this.bookMenuService.getMetadataMenuItems(
|
||||
() => this.updateMetadata(),
|
||||
() => this.bulkEditMetadata(),
|
||||
() => this.multiBookEditMetadata()
|
||||
);
|
||||
|
||||
this.moreActionsMenuItems = [
|
||||
{
|
||||
label: 'Reset Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: 'Are you sure you want to reset progress for selected books?',
|
||||
header: 'Confirm Reset',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
accept: () => {
|
||||
this.resetProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
|
||||
}
|
||||
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
combineLatest({
|
||||
@@ -709,26 +684,4 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
moveFiles() {
|
||||
this.dialogHelperService.openFileMoverDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
resetProgress() {
|
||||
const bookIds = Array.from(this.selectedBooks);
|
||||
this.bookService.resetProgress(bookIds).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset progress.',
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ function getMatchScoreRangeFilters(score?: number | null): { id: string; name: s
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
}
|
||||
|
||||
const readStatusLabels: Record<ReadStatus, string> = {
|
||||
export const readStatusLabels: Record<ReadStatus, string> = {
|
||||
[ReadStatus.UNREAD]: 'Unread',
|
||||
[ReadStatus.READING]: 'Reading',
|
||||
[ReadStatus.RE_READING]: 'Re-reading',
|
||||
|
||||
111
booklore-ui/src/app/book/service/book-menu.service.ts
Normal file
111
booklore-ui/src/app/book/service/book-menu.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
||||
import {MenuItem} from 'primeng/api';
|
||||
import {BookService} from './book.service';
|
||||
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.component';
|
||||
import {ReadStatus} from '../model/book.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookMenuService {
|
||||
|
||||
confirmationService = inject(ConfirmationService);
|
||||
messageService = inject(MessageService);
|
||||
bookService = inject(BookService);
|
||||
|
||||
|
||||
getMetadataMenuItems(updateMetadata: () => void, bulkEditMetadata: () => void, multiBookEditMetadata: () => void): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: 'Refresh Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: updateMetadata
|
||||
},
|
||||
{
|
||||
label: 'Bulk Metadata Editor',
|
||||
icon: 'pi pi-table',
|
||||
command: bulkEditMetadata
|
||||
},
|
||||
{
|
||||
label: 'Multi-Book Metadata Editor',
|
||||
icon: 'pi pi-clone',
|
||||
command: multiBookEditMetadata
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getTieredMenuItems(selectedBooks: Set<number>): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: 'Update Read Status',
|
||||
icon: 'pi pi-book',
|
||||
items: Object.entries(readStatusLabels).map(([status, label]) => ({
|
||||
label,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to mark selected books as "${label}"?`,
|
||||
header: 'Confirm Read Status Update',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
accept: () => {
|
||||
this.bookService.updateBookReadStatus(Array.from(selectedBooks), status as ReadStatus).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Read Status Updated',
|
||||
detail: `Marked as "${label}"`,
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: 'Could not update read status.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Reset Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: 'Are you sure you want to reset progress for selected books?',
|
||||
header: 'Confirm Reset',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
accept: () => {
|
||||
this.bookService.resetProgress(Array.from(selectedBooks)).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset progress.',
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -404,13 +404,14 @@ export class BookService {
|
||||
);
|
||||
}
|
||||
|
||||
updateBookReadStatus(id: number, status: ReadStatus): Observable<void> {
|
||||
return this.http.put<void>(`${this.url}/${id}/read-status`, {status}).pipe(
|
||||
updateBookReadStatus(bookIds: number | number[], status: ReadStatus): Observable<void> {
|
||||
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
|
||||
return this.http.put<void>(`${this.url}/read-status`, {ids, status}).pipe(
|
||||
tap(() => {
|
||||
const currentState = this.bookStateSubject.value;
|
||||
if (!currentState.books) return;
|
||||
const updatedBooks = currentState.books.map(book =>
|
||||
book.id === id ? {...book, readStatus: status} : book
|
||||
ids.includes(book.id) ? {...book, readStatus: status} : book
|
||||
);
|
||||
this.bookStateSubject.next({...currentState, books: updatedBooks});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user