From 645234e66faa706b251381255458c169e5f17686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:22:10 +0100 Subject: [PATCH] feat(bookmark): improve bookmark feature by adding rename, note, color, priority functionalities (#1946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bookmark): add bookmark editing, priority, color, notes, and improved sorting - Add UpdateBookMarkRequest DTO and bookmark editing dialog/component in frontend - Extend BookMark model/entity with color, notes, priority, updatedAt fields - Implement bookmark update API and service logic with validation - Sort bookmarks by priority and creation date - Add Flyway migrations for new columns and index - Update tests for new bookmark features Signed-off-by: Balázs Szücs * fix(bookmark): prevent notes length display error in edit dialog Signed-off-by: Balázs Szücs * fix(bookmark): reset editing state and improve dialog cancel handling Signed-off-by: Balázs Szücs * fix(bookmark): improve edit dialog template with Angular @if and conditional error display Signed-off-by: Balázs Szücs * feat(bookmark): add view dialog, search, and improved display for bookmarks in reader Signed-off-by: Balázs Szücs * feat(bookmark): redesign bookmarks section UI with improved layout, styling, and interactions Signed-off-by: Balázs Szücs * feat(bookmark): enhance view dialog UI with improved layout, styling, and priority display Signed-off-by: Balázs Szücs * chore(migration): rename migration files to maintain sequential versioning Signed-off-by: Balázs Szücs * feat(bookmark): add view and edit actions to bookmark list with improved UI and tooltips Signed-off-by: Balázs Szücs * feat(bookmark): add search and filter functionality to bookmark list in EPUB reader Signed-off-by: Balázs Szücs * feat(bookmark): update search input to use PrimeNG IconField and InputIcon components Signed-off-by: Balázs Szücs --------- Signed-off-by: Balázs Szücs --- .../booklore/BookloreApplication.java | 4 + .../booklore/config/BookmarkProperties.java | 17 ++ .../controller/BookMarkController.java | 18 ++ .../booklore/mapper/BookMarkMapper.java | 3 + .../booklore/model/dto/BookMark.java | 5 + .../model/dto/UpdateBookMarkRequest.java | 33 +++ .../booklore/model/entity/BookMarkEntity.java | 18 ++ .../repository/BookMarkRepository.java | 14 +- .../service/book/BookMarkService.java | 114 +++++++--- ...lor_notes_priority_to_book_marks_table.sql | 9 + .../db/migration/V78__Add_bookmark_index.sql | 2 + .../booklore/service/BookMarkServiceTest.java | 88 +++++++- .../bookmark-edit-dialog.component.ts | 191 +++++++++++++++++ .../bookmark-view-dialog.component.ts | 198 ++++++++++++++++++ .../component/epub-reader.component.html | 172 +++++++++------ .../component/epub-reader.component.scss | 37 ++-- .../component/epub-reader.component.ts | 162 +++++++++++++- .../app/shared/service/book-mark.service.ts | 16 ++ 18 files changed, 977 insertions(+), 124 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/BookmarkProperties.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UpdateBookMarkRequest.java create mode 100644 booklore-api/src/main/resources/db/migration/V77__Add_color_notes_priority_to_book_marks_table.sql create mode 100644 booklore-api/src/main/resources/db/migration/V78__Add_bookmark_index.sql create mode 100644 booklore-ui/src/app/features/readers/epub-reader/component/bookmark-edit-dialog.component.ts create mode 100644 booklore-ui/src/app/features/readers/epub-reader/component/bookmark-view-dialog.component.ts diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/BookloreApplication.java b/booklore-api/src/main/java/com/adityachandel/booklore/BookloreApplication.java index bbf8fbde..d089d65d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/BookloreApplication.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/BookloreApplication.java @@ -2,11 +2,15 @@ package com.adityachandel.booklore; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import com.adityachandel.booklore.config.BookmarkProperties; + @EnableScheduling @EnableAsync +@EnableConfigurationProperties(BookmarkProperties.class) @SpringBootApplication public class BookloreApplication { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/BookmarkProperties.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/BookmarkProperties.java new file mode 100644 index 00000000..1fc0ed87 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/BookmarkProperties.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "bookmarks") +@Data +public class BookmarkProperties { + private int defaultPriority = 3; + private int minPriority = 1; + private int maxPriority = 5; + private int maxNotesLength = 2000; + private int maxTitleLength = 255; + private int maxCfiLength = 500; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMarkController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMarkController.java index 01ca75a2..524f7328 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMarkController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMarkController.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.controller; import com.adityachandel.booklore.model.dto.BookMark; import com.adityachandel.booklore.model.dto.CreateBookMarkRequest; +import com.adityachandel.booklore.model.dto.UpdateBookMarkRequest; import com.adityachandel.booklore.service.book.BookMarkService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -30,6 +31,14 @@ public class BookMarkController { return bookMarkService.getBookmarksForBook(bookId); } + @Operation(summary = "Get a specific bookmark", description = "Retrieve a specific bookmark by its ID.") + @ApiResponse(responseCode = "200", description = "Bookmark returned successfully") + @GetMapping("/{bookmarkId}") + public BookMark getBookmarkById( + @Parameter(description = "ID of the bookmark") @PathVariable Long bookmarkId) { + return bookMarkService.getBookmarkById(bookmarkId); + } + @Operation(summary = "Create a bookmark", description = "Create a new bookmark for a book.") @ApiResponse(responseCode = "200", description = "Bookmark created successfully") @PostMapping @@ -38,6 +47,15 @@ public class BookMarkController { return bookMarkService.createBookmark(request); } + @Operation(summary = "Update a bookmark", description = "Update an existing bookmark's properties (title, location, color, etc.).") + @ApiResponse(responseCode = "200", description = "Bookmark updated successfully") + @PutMapping("/{bookmarkId}") + public BookMark updateBookmark( + @Parameter(description = "ID of the bookmark to update") @PathVariable Long bookmarkId, + @Parameter(description = "Bookmark update request") @Valid @RequestBody UpdateBookMarkRequest request) { + return bookMarkService.updateBookmark(bookmarkId, request); + } + @Operation(summary = "Delete a bookmark", description = "Delete a specific bookmark by its ID.") @ApiResponse(responseCode = "204", description = "Bookmark deleted successfully") @DeleteMapping("/{bookmarkId}") diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMarkMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMarkMapper.java index 0c1283cf..528bf7e0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMarkMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMarkMapper.java @@ -7,5 +7,8 @@ import org.mapstruct.Mapping; @Mapper(componentModel = "spring") public interface BookMarkMapper { + + @Mapping(source = "book.id", target = "bookId") + @Mapping(source = "user.id", target = "userId") BookMark toDto(BookMarkEntity entity); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMark.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMark.java index 78a25927..eaac438d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMark.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMark.java @@ -13,8 +13,13 @@ import java.time.LocalDateTime; @AllArgsConstructor public class BookMark { private Long id; + private Long userId; private Long bookId; private String cfi; private String title; + private String color; + private String notes; + private Integer priority; private LocalDateTime createdAt; + private LocalDateTime updatedAt; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UpdateBookMarkRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UpdateBookMarkRequest.java new file mode 100644 index 00000000..f2b4d5ac --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UpdateBookMarkRequest.java @@ -0,0 +1,33 @@ +package com.adityachandel.booklore.model.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateBookMarkRequest { + + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + @Size(max = 500, message = "CFI must not exceed 500 characters") + private String cfi; + + @Pattern(regexp = "^#[0-9A-Fa-f]{6}$", message = "Color must be a valid hex color (e.g., #FF5733)") + private String color; + + @Size(max = 2000, message = "Notes must not exceed 2000 characters") + private String notes; + + @Min(value = 1, message = "Priority must be at least 1") + @Max(value = 5, message = "Priority must not exceed 5") + private Integer priority; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMarkEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMarkEntity.java index b3eba32b..73a87f29 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMarkEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMarkEntity.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.model.entity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; @@ -39,7 +40,24 @@ public class BookMarkEntity { @Column(name = "title") private String title; + @Column(name = "color") + private String color; + + @Column(name = "notes", length = 2000) + private String notes; + + @Column(name = "priority") + private Integer priority; + + @jakarta.persistence.Version + @Column(name = "version", nullable = false) + private Long version; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMarkRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMarkRepository.java index 5766d125..64d6e33c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMarkRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookMarkRepository.java @@ -9,11 +9,17 @@ import java.util.List; import java.util.Optional; public interface BookMarkRepository extends JpaRepository { - + Optional findByIdAndUserId(Long id, Long userId); - @Query("SELECT b FROM BookMarkEntity b WHERE b.bookId = :bookId AND b.userId = :userId ORDER BY b.createdAt DESC") - List findByBookIdAndUserIdOrderByCreatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId); - + @Query("SELECT b FROM BookMarkEntity b WHERE b.bookId = :bookId AND b.userId = :userId ORDER BY b.priority ASC, b.createdAt DESC") + List findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId); + boolean existsByCfiAndBookIdAndUserId(String cfi, Long bookId, Long userId); + + @Query("SELECT COUNT(b) > 0 FROM BookMarkEntity b WHERE b.cfi = :cfi AND b.bookId = :bookId AND b.userId = :userId AND b.id != :excludeId") + boolean existsByCfiAndBookIdAndUserIdExcludeId(@Param("cfi") String cfi, @Param("bookId") Long bookId, @Param("userId") Long userId, @Param("excludeId") Long excludeId); + + // New: count bookmarks per book + long countByBookIdAndUserId(Long bookId, Long userId); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookMarkService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookMarkService.java index 20d3a475..ca018e33 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookMarkService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookMarkService.java @@ -1,8 +1,10 @@ package com.adityachandel.booklore.service.book; +import com.adityachandel.booklore.config.BookmarkProperties; import com.adityachandel.booklore.mapper.BookMarkMapper; import com.adityachandel.booklore.model.dto.BookMark; import com.adityachandel.booklore.model.dto.CreateBookMarkRequest; +import com.adityachandel.booklore.model.dto.UpdateBookMarkRequest; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.BookMarkEntity; @@ -10,63 +12,125 @@ import com.adityachandel.booklore.repository.BookMarkRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.UserRepository; import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.exception.APIException; import jakarta.persistence.EntityNotFoundException; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor +@Slf4j public class BookMarkService { private final BookMarkRepository bookMarkRepository; private final BookRepository bookRepository; private final UserRepository userRepository; - private final BookMarkMapper mapper; private final AuthenticationService authenticationService; + private final BookMarkMapper mapper; + private final BookmarkProperties bookmarkProperties; @Transactional(readOnly = true) public List getBookmarksForBook(Long bookId) { - Long userId = authenticationService.getAuthenticatedUser().getId(); - return bookMarkRepository.findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId) + Long userId = getCurrentUserId(); + return bookMarkRepository.findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(bookId, userId) .stream() .map(mapper::toDto) .toList(); } + @Transactional(readOnly = true) + public BookMark getBookmarkById(Long bookmarkId) { + return mapper.toDto(findBookmarkByIdAndUser(bookmarkId)); + } + @Transactional public BookMark createBookmark(CreateBookMarkRequest request) { - Long userId = authenticationService.getAuthenticatedUser().getId(); - - // Check for existing bookmark - if (bookMarkRepository.existsByCfiAndBookIdAndUserId(request.getCfi(), request.getBookId(), userId)) { - throw new IllegalArgumentException("Bookmark already exists at this location"); - } - - BookLoreUserEntity currentUser = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("User not found: " + userId)); - - BookEntity book = bookRepository.findById(request.getBookId()) - .orElseThrow(() -> new EntityNotFoundException("Book not found: " + request.getBookId())); + Long userId = getCurrentUserId(); + validateNoDuplicateBookmark(request.getCfi(), request.getBookId(), userId); - BookMarkEntity entity = BookMarkEntity.builder() - .user(currentUser) - .book(book) + BookMarkEntity bookmark = BookMarkEntity.builder() .cfi(request.getCfi()) .title(request.getTitle()) + .book(findBook(request.getBookId())) + .user(findUser(userId)) + .priority(bookmarkProperties.getDefaultPriority()) .build(); - BookMarkEntity saved = bookMarkRepository.save(entity); - return mapper.toDto(saved); + log.info("Creating bookmark for book {} by user {}", request.getBookId(), userId); + return mapper.toDto(bookMarkRepository.save(bookmark)); + } + + @Transactional + public BookMark updateBookmark(Long bookmarkId, UpdateBookMarkRequest request) { + BookMarkEntity bookmark = findBookmarkByIdAndUser(bookmarkId); + + // Validate CFI uniqueness if CFI is being updated + if (request.getCfi() != null) { + validateNoDuplicateBookmark(request.getCfi(), bookmark.getBookId(), bookmark.getUserId(), bookmarkId); + } + + applyUpdates(bookmark, request); + + log.info("Updating bookmark {}", bookmarkId); + return mapper.toDto(bookMarkRepository.save(bookmark)); } @Transactional public void deleteBookmark(Long bookmarkId) { - Long userId = authenticationService.getAuthenticatedUser().getId(); - BookMarkEntity bookmark = bookMarkRepository.findByIdAndUserId(bookmarkId, userId) - .orElseThrow(() -> new EntityNotFoundException("Bookmark not found: " + bookmarkId)); + BookMarkEntity bookmark = findBookmarkByIdAndUser(bookmarkId); + log.info("Deleting bookmark {}", bookmarkId); bookMarkRepository.delete(bookmark); } + + private Long getCurrentUserId() { + return authenticationService.getAuthenticatedUser().getId(); + } + + private BookMarkEntity findBookmarkByIdAndUser(Long bookmarkId) { + Long userId = getCurrentUserId(); + return bookMarkRepository.findByIdAndUserId(bookmarkId, userId) + .orElseThrow(() -> new EntityNotFoundException("Bookmark not found: " + bookmarkId)); + } + + private BookEntity findBook(Long bookId) { + return bookRepository.findById(bookId) + .orElseThrow(() -> new EntityNotFoundException("Book not found: " + bookId)); + } + + private BookLoreUserEntity findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + userId)); + } + + private void validateNoDuplicateBookmark(String cfi, Long bookId, Long userId) { + validateNoDuplicateBookmark(cfi, bookId, userId, null); + } + + /** + * Priority: 1 (highest/most important) to 5 (lowest/least important). + * Bookmarks are sorted by priority ascending (1 first), then by creation date descending. + */ + private void validateNoDuplicateBookmark(String cfi, Long bookId, Long userId, Long excludeBookmarkId) { + boolean exists = (excludeBookmarkId == null) + ? bookMarkRepository.existsByCfiAndBookIdAndUserId(cfi, bookId, userId) + : bookMarkRepository.existsByCfiAndBookIdAndUserIdExcludeId(cfi, bookId, userId, excludeBookmarkId); + + if (exists) { + throw new APIException("Bookmark already exists at this location", HttpStatus.CONFLICT); + } + } + + private void applyUpdates(BookMarkEntity bookmark, UpdateBookMarkRequest request) { + Optional.ofNullable(request.getTitle()).ifPresent(bookmark::setTitle); + Optional.ofNullable(request.getCfi()).ifPresent(bookmark::setCfi); + Optional.ofNullable(request.getColor()).ifPresent(bookmark::setColor); + Optional.ofNullable(request.getNotes()).ifPresent(bookmark::setNotes); + Optional.ofNullable(request.getPriority()).ifPresent(bookmark::setPriority); + } } diff --git a/booklore-api/src/main/resources/db/migration/V77__Add_color_notes_priority_to_book_marks_table.sql b/booklore-api/src/main/resources/db/migration/V77__Add_color_notes_priority_to_book_marks_table.sql new file mode 100644 index 00000000..1665de32 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V77__Add_color_notes_priority_to_book_marks_table.sql @@ -0,0 +1,9 @@ +ALTER TABLE book_marks + ADD COLUMN color VARCHAR(7) DEFAULT NULL, + ADD COLUMN notes VARCHAR(2000) DEFAULT NULL, + ADD COLUMN priority INTEGER DEFAULT NULL, + ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN version BIGINT NOT NULL DEFAULT 1; + +-- Update existing records +UPDATE book_marks SET updated_at = created_at WHERE updated_at IS NULL; \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V78__Add_bookmark_index.sql b/booklore-api/src/main/resources/db/migration/V78__Add_bookmark_index.sql new file mode 100644 index 00000000..7fcee561 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V78__Add_bookmark_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_bookmark_book_user_priority +ON book_marks(book_id, user_id, priority, created_at); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookMarkServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookMarkServiceTest.java index 792c5889..84990209 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookMarkServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookMarkServiceTest.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.service; +import com.adityachandel.booklore.config.BookmarkProperties; import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.mapper.BookMarkMapper; import com.adityachandel.booklore.model.dto.BookLoreUser; @@ -40,6 +41,8 @@ class BookMarkServiceTest { private BookMarkMapper mapper; @Mock private AuthenticationService authenticationService; + @Mock + private BookmarkProperties bookmarkProperties; @InjectMocks private BookMarkService bookMarkService; @@ -58,14 +61,14 @@ class BookMarkServiceTest { userDto = BookLoreUser.builder().id(userId).build(); userEntity = BookLoreUserEntity.builder().id(userId).build(); bookEntity = BookEntity.builder().id(bookId).build(); - bookmarkEntity = BookMarkEntity.builder().id(bookmarkId).user(userEntity).book(bookEntity).cfi("cfi").title("title").build(); + bookmarkEntity = BookMarkEntity.builder().id(bookmarkId).user(userEntity).book(bookEntity).cfi("cfi").title("title").version(1L).build(); bookmarkDto = BookMark.builder().id(bookmarkId).bookId(bookId).cfi("cfi").title("title").build(); } @Test void getBookmarksForBook_Success() { when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); - when(bookMarkRepository.findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId)).thenReturn(List.of(bookmarkEntity)); + when(bookMarkRepository.findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(bookId, userId)).thenReturn(List.of(bookmarkEntity)); when(mapper.toDto(bookmarkEntity)).thenReturn(bookmarkDto); List result = bookMarkService.getBookmarksForBook(bookId); @@ -73,7 +76,7 @@ class BookMarkServiceTest { assertNotNull(result); assertEquals(1, result.size()); assertEquals(bookmarkId, result.get(0).getId()); - verify(bookMarkRepository).findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId); + verify(bookMarkRepository).findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(bookId, userId); } @Test @@ -81,6 +84,7 @@ class BookMarkServiceTest { CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark"); when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookmarkProperties.getDefaultPriority()).thenReturn(3); when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false); when(userRepository.findById(userId)).thenReturn(Optional.of(userEntity)); when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity)); @@ -94,16 +98,26 @@ class BookMarkServiceTest { verify(bookMarkRepository).save(any(BookMarkEntity.class)); } + @Test + void createBookmark_Duplicate() { + CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark"); + + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(true); // Duplicate exists + + assertThrows(com.adityachandel.booklore.exception.APIException.class, () -> bookMarkService.createBookmark(request)); + verify(bookMarkRepository, never()).save(any()); + } + @Test void createBookmark_BookNotFound() { CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark"); - - when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); - when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(userEntity)); - when(bookRepository.findById(bookId)).thenReturn(Optional.empty()); - assertThrows(EntityNotFoundException.class, () -> bookMarkService.createBookmark(request)); + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false); // No duplicate + when(bookRepository.findById(bookId)).thenReturn(Optional.empty()); // Book doesn't exist + + assertThrows(jakarta.persistence.EntityNotFoundException.class, () -> bookMarkService.createBookmark(request)); verify(bookMarkRepository, never()).save(any()); } @@ -125,4 +139,60 @@ class BookMarkServiceTest { assertThrows(EntityNotFoundException.class, () -> bookMarkService.deleteBookmark(bookmarkId)); verify(bookMarkRepository, never()).delete(any()); } + + @Test + void updateBookmark_Success() { + var updateRequest = com.adityachandel.booklore.model.dto.UpdateBookMarkRequest.builder() + .title("Updated Title") + .color("#FF0000") + .notes("Updated notes") + .priority(3) + .build(); + + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.findByIdAndUserId(bookmarkId, userId)).thenReturn(Optional.of(bookmarkEntity)); + when(bookMarkRepository.save(any(BookMarkEntity.class))).thenReturn(bookmarkEntity); + when(mapper.toDto(bookmarkEntity)).thenReturn(bookmarkDto); + + BookMark result = bookMarkService.updateBookmark(bookmarkId, updateRequest); + + assertNotNull(result); + assertEquals(bookmarkId, result.getId()); + verify(bookMarkRepository).save(any(BookMarkEntity.class)); + } + + @Test + void updateBookmark_NotFound() { + var updateRequest = com.adityachandel.booklore.model.dto.UpdateBookMarkRequest.builder() + .title("Updated Title") + .build(); + + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.findByIdAndUserId(bookmarkId, userId)).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> bookMarkService.updateBookmark(bookmarkId, updateRequest)); + verify(bookMarkRepository, never()).save(any()); + } + + @Test + void getBookmarkById_Success() { + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.findByIdAndUserId(bookmarkId, userId)).thenReturn(Optional.of(bookmarkEntity)); + when(mapper.toDto(bookmarkEntity)).thenReturn(bookmarkDto); + + BookMark result = bookMarkService.getBookmarkById(bookmarkId); + + assertNotNull(result); + assertEquals(bookmarkId, result.getId()); + verify(bookMarkRepository).findByIdAndUserId(bookmarkId, userId); + } + + @Test + void getBookmarkById_NotFound() { + when(authenticationService.getAuthenticatedUser()).thenReturn(userDto); + when(bookMarkRepository.findByIdAndUserId(bookmarkId, userId)).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> bookMarkService.getBookmarkById(bookmarkId)); + verify(bookMarkRepository).findByIdAndUserId(bookmarkId, userId); + } } diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-edit-dialog.component.ts b/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-edit-dialog.component.ts new file mode 100644 index 00000000..4f88bef7 --- /dev/null +++ b/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-edit-dialog.component.ts @@ -0,0 +1,191 @@ +import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Dialog } from 'primeng/dialog'; +import { InputText } from 'primeng/inputtext'; +import { ColorPicker } from 'primeng/colorpicker'; +import { Textarea } from 'primeng/textarea'; +import { InputNumber } from 'primeng/inputnumber'; +import { Button } from 'primeng/button'; +import { BookMark, UpdateBookMarkRequest } from '../../../../shared/service/book-mark.service'; +import { PrimeTemplate } from 'primeng/api'; + +export interface BookmarkFormData { + title: string; + color: string; + notes: string; + priority: number | null; +} + +@Component({ + selector: 'app-bookmark-edit-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + Dialog, + InputText, + ColorPicker, + Textarea, + InputNumber, + Button, + PrimeTemplate + ], + template: ` + + + @if (formData) { +
+
+ + + @if (titleError) { + Title is required + } +
+ +
+ +
+ + + +
+
+ +
+ + + {{ formData.notes.length || 0 }}/2000 +
+ +
+ + + +
+
+ } + + +
+ + + + +
+
+
+ ` +}) +export class BookmarkEditDialogComponent implements OnChanges { + @Input() visible = false; + @Input() bookmark: BookMark | null = null; + @Input() isSaving = false; + + @Output() visibleChange = new EventEmitter(); + @Output() save = new EventEmitter(); + @Output() cancelEdit = new EventEmitter(); + + formData: BookmarkFormData | null = null; + titleError = false; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['bookmark'] && this.bookmark) { + this.titleError = false; + this.formData = { + title: this.bookmark.title || '', + color: this.bookmark.color || '#3B82F6', + notes: this.bookmark.notes || '', + priority: this.bookmark.priority ?? 3 + }; + } + } + + onSave(): void { + if (!this.formData) return; + + if (!this.formData.title || !this.formData.title.trim()) { + this.titleError = true; + return; + } + + const request: UpdateBookMarkRequest = { + title: this.formData.title.trim(), + color: this.formData.color || undefined, + notes: this.formData.notes || undefined, + priority: this.formData.priority ?? undefined + }; + + this.save.emit(request); + } + + onDialogHide(): void { + // When dialog is closed via X button, treat it as cancel + this.onCancel(); + } + + onCancel(): void { + this.formData = null; // Clear form data + this.visible = false; + this.visibleChange.emit(false); + this.cancelEdit.emit(); + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-view-dialog.component.ts b/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-view-dialog.component.ts new file mode 100644 index 00000000..82ebb249 --- /dev/null +++ b/booklore-ui/src/app/features/readers/epub-reader/component/bookmark-view-dialog.component.ts @@ -0,0 +1,198 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Dialog } from 'primeng/dialog'; +import { Button } from 'primeng/button'; +import { BookMark } from '../../../../shared/service/book-mark.service'; +import { PrimeTemplate } from 'primeng/api'; + +@Component({ + selector: 'app-bookmark-view-dialog', + standalone: true, + imports: [ + CommonModule, + Dialog, + Button, + PrimeTemplate + ], + template: ` + + + @if (bookmark) { +
+
+ +

{{ bookmark.title }}

+
+ +
+
+ Created + {{ bookmark.createdAt | date:'MMM d, y, h:mm a' }} +
+
+ Priority + + {{ getPriorityLabel(bookmark.priority) }} + +
+
+ +
+ Notes + @if (bookmark.notes) { +

{{ bookmark.notes }}

+ } @else { +

No notes added

+ } +
+
+ } + + + + + +
+ `, + styles: [` + .bookmark-view-content { + padding: 0.5rem 0; + } + + .bookmark-view-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--surface-border); + } + + .bookmark-view-color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + } + + .bookmark-view-title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-color); + word-break: break-word; + } + + .bookmark-view-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.25rem; + } + + .bookmark-view-row { + display: flex; + justify-content: space-between; + align-items: center; + } + + .bookmark-view-label { + font-size: 0.875rem; + color: var(--text-color-secondary); + } + + .bookmark-view-value { + font-size: 0.875rem; + color: var(--text-color); + } + + .bookmark-view-priority { + font-size: 0.8rem; + font-weight: 500; + padding: 0.25rem 0.625rem; + border-radius: 1rem; + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + color: var(--primary-color); + } + + .bookmark-view-priority[data-priority="1"] { + background: color-mix(in srgb, #ef4444 15%, transparent); + color: #ef4444; + } + + .bookmark-view-priority[data-priority="2"] { + background: color-mix(in srgb, #f97316 15%, transparent); + color: #f97316; + } + + .bookmark-view-priority[data-priority="3"] { + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + color: var(--primary-color); + } + + .bookmark-view-priority[data-priority="4"], + .bookmark-view-priority[data-priority="5"] { + background: color-mix(in srgb, #6b7280 15%, transparent); + color: #6b7280; + } + + .bookmark-view-notes { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .bookmark-view-notes-content { + margin: 0; + padding: 0.875rem; + background: var(--surface-ground); + border-radius: 0.5rem; + font-size: 0.875rem; + color: var(--text-color); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + } + + .bookmark-view-notes-empty { + margin: 0; + font-size: 0.875rem; + color: var(--text-color-secondary); + font-style: italic; + } + `] +}) +export class BookmarkViewDialogComponent { + @Input() visible = false; + @Input() bookmark: BookMark | null = null; + @Output() visibleChange = new EventEmitter(); + + onClose(): void { + this.visible = false; + this.visibleChange.emit(false); + } + + getPriorityLabel(priority: number | undefined): string { + if (priority === undefined) return 'Normal'; + if (priority <= 1) return 'Highest'; + if (priority === 2) return 'High'; + if (priority === 3) return 'Normal'; + if (priority === 4) return 'Low'; + return 'Lowest'; + } +} diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html index 283fd365..be1b2605 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html @@ -12,72 +12,101 @@
Progress: {{ progressPercentage }}%
- - -
- Table of Contents -
-
+ + +
+ Table of Contents +
+
- - - -
- - Chapters + + + +
+ + Chapters +
+
+ +
+ + Bookmarks +
+
+
+ + +
+
    + @for (chapter of chapters; track chapter) { +
  • + + {{ chapter.label }} +
  • + } +
+
+
+ +
+
+ + + + +
+ @if (filteredBookmarks.length === 0) { +
+ +

No bookmarks found

+ {{ filterText ? 'Try a different search term' : 'Tap the bookmark icon to save your place' }} +
+ } @else { +
    + @for (bookmark of filteredBookmarks; track bookmark.id) { +
  • + + {{ bookmark.title }} +
    + + +
    - - -
    - - Bookmarks -
    -
    - - - -
    -
      - @for (chapter of chapters; track chapter) { -
    • - - {{ chapter.label }} -
    • - } -
    -
    -
    - -
    - @if (bookmarks.length === 0) { -
    - -

    No bookmarks yet

    - Tap the bookmark icon to save your place -
    - } @else { -
      - @for (bookmark of bookmarks; track bookmark.id) { -
    • - - {{ bookmark.title }} - -
    • - } -
    - } -
    -
    -
    - - +
  • + } +
+ } +
+
+
+
+
@@ -268,3 +297,18 @@
} + + + + + + + + diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.scss b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.scss index 9ed15d1d..05ccfd79 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.scss +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.scss @@ -308,8 +308,15 @@ font-weight: 500; } - .bookmark-delete { + .bookmark-actions { opacity: 0; + display: flex; + gap: 0.25rem; + margin-left: auto; + transition: opacity 0.2s ease; + } + + .bookmark-action-btn { background: transparent; border: none; cursor: pointer; @@ -318,9 +325,16 @@ color: color-mix(in srgb, var(--text-color) 60%, transparent); transition: all 0.2s ease; flex-shrink: 0; - margin-left: auto; + display: flex; + align-items: center; + justify-content: center; &:hover { + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + color: var(--primary-color); + } + + &.delete:hover { background: color-mix(in srgb, #ef4444 15%, transparent); color: #ef4444; } @@ -334,7 +348,7 @@ background-color: color-mix(in srgb, var(--primary-color) 8%, transparent); transform: translateX(4px); - .bookmark-delete { + .bookmark-actions { opacity: 1; } } @@ -363,24 +377,7 @@ } } -.bookmark-delete { - background: transparent; - border: none; - cursor: pointer; - padding: 0.5rem; - border-radius: 0.375rem; - color: color-mix(in srgb, var(--text-color) 60%, transparent); - transition: all 0.2s ease; - &:hover { - background: color-mix(in srgb, #ef4444 15%, transparent); - color: #ef4444; - } - - i { - font-size: 0.875rem; - } -} .empty-state { display: flex; diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts index c8d1d162..43639939 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts @@ -3,6 +3,8 @@ import ePub from 'epubjs'; import {Drawer} from 'primeng/drawer'; import {forkJoin, Subscription} from 'rxjs'; import {Button} from 'primeng/button'; +import {InputText} from 'primeng/inputtext'; +import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {ActivatedRoute} from '@angular/router'; import {Book, BookSetting} from '../../../book/model/book.model'; @@ -11,19 +13,43 @@ import {Select} from 'primeng/select'; import {UserService} from '../../../settings/user-management/user.service'; import {ProgressSpinner} from 'primeng/progressspinner'; import {MessageService, PrimeTemplate} from 'primeng/api'; -import {BookMark, BookMarkService} from '../../../../shared/service/book-mark.service'; +import {BookMark, BookMarkService, UpdateBookMarkRequest} from '../../../../shared/service/book-mark.service'; import {Tooltip} from 'primeng/tooltip'; import {Slider} from 'primeng/slider'; import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper'; import {EpubThemeUtil, EpubTheme} from '../epub-theme-util'; import {PageTitleService} from "../../../../shared/service/page-title.service"; import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs'; +import {IconField} from 'primeng/iconfield'; +import {InputIcon} from 'primeng/inputicon'; +import {BookmarkEditDialogComponent} from './bookmark-edit-dialog.component'; +import {BookmarkViewDialogComponent} from './bookmark-view-dialog.component'; @Component({ selector: 'app-epub-reader', templateUrl: './epub-reader.component.html', styleUrls: ['./epub-reader.component.scss'], - imports: [Drawer, Button, FormsModule, Select, ProgressSpinner, Tooltip, Slider, PrimeTemplate, Tabs, TabList, Tab, TabPanels, TabPanel], + imports: [ + CommonModule, + FormsModule, + Drawer, + Button, + Select, + ProgressSpinner, + Tooltip, + Slider, + PrimeTemplate, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + IconField, + InputIcon, + BookmarkEditDialogComponent, + BookmarkViewDialogComponent, + InputText + ], standalone: true }) export class EpubReaderComponent implements OnInit, OnDestroy { @@ -40,8 +66,15 @@ export class EpubReaderComponent implements OnInit, OnDestroy { isBookmarked = false; isAddingBookmark = false; isDeletingBookmark = false; + isEditingBookmark = false; + isUpdatingPosition = false; private routeSubscription?: Subscription; + // Bookmark Filter & View + filterText = ''; + viewDialogVisible = false; + selectedBookmark: BookMark | null = null; + public locationsReady = false; public approxProgress = 0; public exactProgress = 0; @@ -54,6 +87,10 @@ export class EpubReaderComponent implements OnInit, OnDestroy { private isMouseInTopRegion = false; private headerShownByMobileTouch = false; + // Properties for bookmark editing + editingBookmark: BookMark | null = null; + showEditBookmarkDialog = false; + private book: any; private rendition: any; private keyListener: (e: KeyboardEvent) => void = () => { @@ -209,6 +246,39 @@ export class EpubReaderComponent implements OnInit, OnDestroy { }); } + get filteredBookmarks(): BookMark[] { + let filtered = this.bookmarks; + + // Filter + if (this.filterText && this.filterText.trim()) { + const lowerFilter = this.filterText.toLowerCase().trim(); + filtered = filtered.filter(b => + (b.title && b.title.toLowerCase().includes(lowerFilter)) || + (b.notes && b.notes.toLowerCase().includes(lowerFilter)) + ); + } + + // Sort: Priority ASC (1 is high), then CreatedAt DESC + return [...filtered].sort((a, b) => { + const priorityA = a.priority ?? 3; // Default to 3 (Normal) if undefined + const priorityB = b.priority ?? 3; + + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + + return dateB - dateA; + }); + } + + openViewDialog(bookmark: BookMark): void { + this.selectedBookmark = bookmark; + this.viewDialogVisible = true; + } + updateThemeStyle(): void { this.applyCombinedTheme(); this.updateViewerSetting(); @@ -620,6 +690,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy { this.bookMarkService.createBookmark(request).subscribe({ next: (bookmark) => { this.bookmarks.push(bookmark); + // Force array update for change detection if needed, but simple push works with getter usually if ref is stable + this.bookmarks = [...this.bookmarks]; this.updateBookmarkStatus(); this.messageService.add({ severity: 'success', @@ -643,6 +715,11 @@ export class EpubReaderComponent implements OnInit, OnDestroy { if (this.isDeletingBookmark) { return; } + // Simple confirmation using window.confirm for now, as consistent with UserManagementComponent behavior seen in linting + if (!confirm('Are you sure you want to delete this bookmark?')) { + return; + } + this.isDeletingBookmark = true; this.bookMarkService.deleteBookmark(bookmarkId).subscribe({ next: () => { @@ -682,4 +759,85 @@ export class EpubReaderComponent implements OnInit, OnDestroy { ? this.bookmarks.some(b => b.cfi === this.currentCfi) : false; } + + openEditBookmarkDialog(bookmark: BookMark): void { + this.editingBookmark = { ...bookmark }; + this.showEditBookmarkDialog = true; + } + + onBookmarkSave(updateRequest: UpdateBookMarkRequest): void { + if (!this.editingBookmark || this.isEditingBookmark) { + return; + } + + this.isEditingBookmark = true; + + this.bookMarkService.updateBookmark(this.editingBookmark.id, updateRequest).subscribe({ + next: (updatedBookmark) => { + const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark!.id); + if (index !== -1) { + this.bookmarks[index] = updatedBookmark; + this.bookmarks = [...this.bookmarks]; // Trigger change detection for getter + } + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Bookmark updated successfully', + }); + this.showEditBookmarkDialog = false; + this.editingBookmark = null; // Reset the editing bookmark after successful save + this.isEditingBookmark = false; + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update bookmark', + }); + this.showEditBookmarkDialog = false; + this.editingBookmark = null; // Reset the editing bookmark even on error + this.isEditingBookmark = false; + } + }); + } + + onBookmarkCancel(): void { + this.showEditBookmarkDialog = false; + this.editingBookmark = null; // Reset the editing bookmark when dialog is cancelled + } + + updateBookmarkPosition(bookmarkId: number): void { + if (!this.currentCfi || this.isUpdatingPosition) { + return; + } + + this.isUpdatingPosition = true; + const updateRequest = { + cfi: this.currentCfi + }; + + this.bookMarkService.updateBookmark(bookmarkId, updateRequest).subscribe({ + next: (updatedBookmark) => { + const index = this.bookmarks.findIndex(b => b.id === bookmarkId); + if (index !== -1) { + this.bookmarks[index] = updatedBookmark; + this.bookmarks = [...this.bookmarks]; + } + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Bookmark position updated successfully', + }); + this.isUpdatingPosition = false; + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update bookmark position', + }); + this.isUpdatingPosition = false; + } + }); + } } diff --git a/booklore-ui/src/app/shared/service/book-mark.service.ts b/booklore-ui/src/app/shared/service/book-mark.service.ts index 85644068..e385bb64 100644 --- a/booklore-ui/src/app/shared/service/book-mark.service.ts +++ b/booklore-ui/src/app/shared/service/book-mark.service.ts @@ -5,10 +5,15 @@ import {API_CONFIG} from '../../core/config/api-config'; export interface BookMark { id: number; + userId?: number; bookId: number; cfi: string; title: string; + color?: string; + notes?: string; + priority?: number; createdAt: string; + updatedAt?: string; } export interface CreateBookMarkRequest { @@ -17,6 +22,14 @@ export interface CreateBookMarkRequest { title?: string; } +export interface UpdateBookMarkRequest { + title?: string; + cfi?: string; + color?: string; + notes?: string; + priority?: number; +} + @Injectable({ providedIn: 'root' }) @@ -36,4 +49,7 @@ export class BookMarkService { deleteBookmark(bookmarkId: number): Observable { return this.http.delete(`${this.url}/${bookmarkId}`); } + updateBookmark(bookmarkId: number, request: UpdateBookMarkRequest): Observable { + return this.http.put(`${this.url}/${bookmarkId}`, request); + } }