mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
feat(bookmark): improve bookmark feature by adding rename, note, color, priority functionalities (#1946)
* 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 <bszucs1209@gmail.com> * fix(bookmark): prevent notes length display error in edit dialog Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(bookmark): reset editing state and improve dialog cancel handling Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(bookmark): improve edit dialog template with Angular @if and conditional error display Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add view dialog, search, and improved display for bookmarks in reader Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): redesign bookmarks section UI with improved layout, styling, and interactions Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): enhance view dialog UI with improved layout, styling, and priority display Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * chore(migration): rename migration files to maintain sequential versioning Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add view and edit actions to bookmark list with improved UI and tooltips Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): add search and filter functionality to bookmark list in EPUB reader Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(bookmark): update search input to use PrimeNG IconField and InputIcon components Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> --------- Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@ public interface BookMarkRepository extends JpaRepository<BookMarkEntity, Long>
|
||||
|
||||
Optional<BookMarkEntity> 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<BookMarkEntity> 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<BookMarkEntity> 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);
|
||||
}
|
||||
|
||||
@@ -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<BookMark> 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
|
||||
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");
|
||||
@Transactional(readOnly = true)
|
||||
public BookMark getBookmarkById(Long bookmarkId) {
|
||||
return mapper.toDto(findBookmarkByIdAndUser(bookmarkId));
|
||||
}
|
||||
|
||||
BookLoreUserEntity currentUser = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
|
||||
@Transactional
|
||||
public BookMark createBookmark(CreateBookMarkRequest request) {
|
||||
Long userId = getCurrentUserId();
|
||||
validateNoDuplicateBookmark(request.getCfi(), request.getBookId(), userId);
|
||||
|
||||
BookEntity book = bookRepository.findById(request.getBookId())
|
||||
.orElseThrow(() -> new EntityNotFoundException("Book not found: " + request.getBookId()));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX idx_bookmark_book_user_priority
|
||||
ON book_marks(book_id, user_id, priority, created_at);
|
||||
@@ -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<BookMark> 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());
|
||||
when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false); // No duplicate
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.empty()); // Book doesn't exist
|
||||
|
||||
assertThrows(EntityNotFoundException.class, () -> bookMarkService.createBookmark(request));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<p-dialog
|
||||
[(visible)]="visible"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[style]="{width: '500px'}"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
[closeOnEscape]="true"
|
||||
[appendTo]="'body'"
|
||||
header="Edit Bookmark"
|
||||
(onHide)="onDialogHide()">
|
||||
|
||||
@if (formData) {
|
||||
<div class="p-4">
|
||||
<div class="field mb-4">
|
||||
<label for="title" class="block text-sm font-medium mb-2">Title <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
pInputText
|
||||
id="title"
|
||||
type="text"
|
||||
[(ngModel)]="formData.title"
|
||||
class="w-full"
|
||||
[class.ng-invalid]="titleError"
|
||||
[class.ng-dirty]="titleError"
|
||||
placeholder="Enter bookmark title"
|
||||
[maxlength]="255"
|
||||
(ngModelChange)="titleError = false">
|
||||
@if (titleError) {
|
||||
<small class="text-red-500">Title is required</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="color" class="block text-sm font-medium mb-2">Color</label>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<p-colorPicker
|
||||
[(ngModel)]="formData.color"
|
||||
[appendTo]="'body'"
|
||||
format="hex">
|
||||
</p-colorPicker>
|
||||
<input
|
||||
pInputText
|
||||
[(ngModel)]="formData.color"
|
||||
class="w-8rem"
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="notes" class="block text-sm font-medium mb-2">Notes</label>
|
||||
<textarea
|
||||
pInputTextarea
|
||||
id="notes"
|
||||
[(ngModel)]="formData.notes"
|
||||
class="w-full"
|
||||
rows="3"
|
||||
placeholder="Add notes about this bookmark"
|
||||
[maxlength]="2000">
|
||||
</textarea>
|
||||
<small class="text-muted">{{ formData.notes.length || 0 }}/2000</small>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label for="priority" class="block text-sm font-medium mb-2">Priority (1 = High, 5 = Low)</label>
|
||||
<p-inputNumber
|
||||
id="priority"
|
||||
[(ngModel)]="formData.priority"
|
||||
[min]="1"
|
||||
[max]="5"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
spinnerMode="horizontal"
|
||||
decrementButtonClass="p-button-secondary"
|
||||
incrementButtonClass="p-button-secondary"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
incrementButtonIcon="pi pi-plus">
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="flex justify-content-between">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
icon="pi pi-times"
|
||||
(click)="onCancel()"
|
||||
[text]="true"
|
||||
severity="secondary">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
(click)="onSave()"
|
||||
[loading]="isSaving"
|
||||
[disabled]="!formData || isSaving">
|
||||
</p-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
`
|
||||
})
|
||||
export class BookmarkEditDialogComponent implements OnChanges {
|
||||
@Input() visible = false;
|
||||
@Input() bookmark: BookMark | null = null;
|
||||
@Input() isSaving = false;
|
||||
|
||||
@Output() visibleChange = new EventEmitter<boolean>();
|
||||
@Output() save = new EventEmitter<UpdateBookMarkRequest>();
|
||||
@Output() cancelEdit = new EventEmitter<void>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<p-dialog
|
||||
[(visible)]="visible"
|
||||
[modal]="true"
|
||||
[closable]="true"
|
||||
[style]="{width: '420px', maxWidth: '95vw'}"
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
[closeOnEscape]="true"
|
||||
[appendTo]="'body'"
|
||||
header="View Bookmark"
|
||||
(onHide)="onClose()">
|
||||
|
||||
@if (bookmark) {
|
||||
<div class="bookmark-view-content">
|
||||
<div class="bookmark-view-header">
|
||||
<span class="bookmark-view-color" [style.background-color]="bookmark.color || 'var(--primary-color)'"></span>
|
||||
<h3 class="bookmark-view-title">{{ bookmark.title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-view-details">
|
||||
<div class="bookmark-view-row">
|
||||
<span class="bookmark-view-label">Created</span>
|
||||
<span class="bookmark-view-value">{{ bookmark.createdAt | date:'MMM d, y, h:mm a' }}</span>
|
||||
</div>
|
||||
<div class="bookmark-view-row">
|
||||
<span class="bookmark-view-label">Priority</span>
|
||||
<span class="bookmark-view-priority" [attr.data-priority]="bookmark.priority">
|
||||
{{ getPriorityLabel(bookmark.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-view-notes">
|
||||
<span class="bookmark-view-label">Notes</span>
|
||||
@if (bookmark.notes) {
|
||||
<p class="bookmark-view-notes-content">{{ bookmark.notes }}</p>
|
||||
} @else {
|
||||
<p class="bookmark-view-notes-empty">No notes added</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template pTemplate="footer">
|
||||
<p-button
|
||||
label="Close"
|
||||
icon="pi pi-times"
|
||||
(click)="onClose()"
|
||||
[text]="true"
|
||||
severity="secondary">
|
||||
</p-button>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
`,
|
||||
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<boolean>();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="progress-info">
|
||||
<span class="progress-percentage"><span class="progress-label">Progress: </span>{{ progressPercentage }}%</span>
|
||||
</div>
|
||||
<p-drawer [(visible)]="isDrawerVisible" [modal]="false" [position]="'left'" [style]="{ width: '320px' }">
|
||||
<p-drawer [(visible)]="isDrawerVisible" [modal]="false" [position]="'left'" [style]="{ width: '320px' }">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-title">Table of Contents</span>
|
||||
@@ -52,23 +52,52 @@
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="1">
|
||||
<div class="tab-content">
|
||||
@if (bookmarks.length === 0) {
|
||||
<div class="p-2 mb-2">
|
||||
<p-iconfield class="w-full">
|
||||
<p-inputicon class="pi pi-search"/>
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
[(ngModel)]="filterText"
|
||||
placeholder="Search bookmarks..."
|
||||
class="w-full p-inputtext-sm">
|
||||
</p-iconfield>
|
||||
</div>
|
||||
@if (filteredBookmarks.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<p>No bookmarks yet</p>
|
||||
<span>Tap the bookmark icon to save your place</span>
|
||||
<p>No bookmarks found</p>
|
||||
<span>{{ filterText ? 'Try a different search term' : 'Tap the bookmark icon to save your place' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="bookmark-list">
|
||||
@for (bookmark of bookmarks; track bookmark.id) {
|
||||
@for (bookmark of filteredBookmarks; track bookmark.id) {
|
||||
<li class="bookmark-item" (click)="navigateToBookmark(bookmark); $event.stopPropagation()">
|
||||
<i class="pi pi-bookmark-fill bookmark-icon"></i>
|
||||
<i class="pi pi-bookmark-fill bookmark-icon" [style.color]="bookmark.color || 'var(--primary-color)'"></i>
|
||||
<span class="bookmark-label">{{ bookmark.title }}</span>
|
||||
<div class="bookmark-actions">
|
||||
<button
|
||||
class="bookmark-delete"
|
||||
(click)="deleteBookmark(bookmark.id); $event.stopPropagation()">
|
||||
class="bookmark-action-btn"
|
||||
(click)="openViewDialog(bookmark); $event.stopPropagation()"
|
||||
pTooltip="View Details"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
</button>
|
||||
<button
|
||||
class="bookmark-action-btn"
|
||||
(click)="openEditBookmarkDialog(bookmark); $event.stopPropagation()"
|
||||
pTooltip="Edit"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
class="bookmark-action-btn delete"
|
||||
(click)="deleteBookmark(bookmark.id); $event.stopPropagation()"
|
||||
pTooltip="Delete"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -77,7 +106,7 @@
|
||||
</p-tabpanel>
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</p-drawer>
|
||||
</p-drawer>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
@@ -268,3 +297,18 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Bookmark Edit Dialog Component -->
|
||||
<app-bookmark-edit-dialog
|
||||
[(visible)]="showEditBookmarkDialog"
|
||||
[bookmark]="editingBookmark"
|
||||
[isSaving]="isEditingBookmark"
|
||||
(save)="onBookmarkSave($event)"
|
||||
(cancelEdit)="onBookmarkCancel()">
|
||||
</app-bookmark-edit-dialog>
|
||||
|
||||
<!-- Bookmark View Dialog Component -->
|
||||
<app-bookmark-view-dialog
|
||||
[(visible)]="viewDialogVisible"
|
||||
[bookmark]="selectedBookmark">
|
||||
</app-bookmark-view-dialog>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
return this.http.delete<void>(`${this.url}/${bookmarkId}`);
|
||||
}
|
||||
updateBookmark(bookmarkId: number, request: UpdateBookMarkRequest): Observable<BookMark> {
|
||||
return this.http.put<BookMark>(`${this.url}/${bookmarkId}`, request);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user