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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.config.BookmarkProperties;
|
||||||
|
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableConfigurationProperties(BookmarkProperties.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class BookloreApplication {
|
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.BookMark;
|
||||||
import com.adityachandel.booklore.model.dto.CreateBookMarkRequest;
|
import com.adityachandel.booklore.model.dto.CreateBookMarkRequest;
|
||||||
|
import com.adityachandel.booklore.model.dto.UpdateBookMarkRequest;
|
||||||
import com.adityachandel.booklore.service.book.BookMarkService;
|
import com.adityachandel.booklore.service.book.BookMarkService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@@ -30,6 +31,14 @@ public class BookMarkController {
|
|||||||
return bookMarkService.getBookmarksForBook(bookId);
|
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.")
|
@Operation(summary = "Create a bookmark", description = "Create a new bookmark for a book.")
|
||||||
@ApiResponse(responseCode = "200", description = "Bookmark created successfully")
|
@ApiResponse(responseCode = "200", description = "Bookmark created successfully")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -38,6 +47,15 @@ public class BookMarkController {
|
|||||||
return bookMarkService.createBookmark(request);
|
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.")
|
@Operation(summary = "Delete a bookmark", description = "Delete a specific bookmark by its ID.")
|
||||||
@ApiResponse(responseCode = "204", description = "Bookmark deleted successfully")
|
@ApiResponse(responseCode = "204", description = "Bookmark deleted successfully")
|
||||||
@DeleteMapping("/{bookmarkId}")
|
@DeleteMapping("/{bookmarkId}")
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ import org.mapstruct.Mapping;
|
|||||||
|
|
||||||
@Mapper(componentModel = "spring")
|
@Mapper(componentModel = "spring")
|
||||||
public interface BookMarkMapper {
|
public interface BookMarkMapper {
|
||||||
|
|
||||||
|
@Mapping(source = "book.id", target = "bookId")
|
||||||
|
@Mapping(source = "user.id", target = "userId")
|
||||||
BookMark toDto(BookMarkEntity entity);
|
BookMark toDto(BookMarkEntity entity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import java.time.LocalDateTime;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class BookMark {
|
public class BookMark {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
private Long bookId;
|
private Long bookId;
|
||||||
private String cfi;
|
private String cfi;
|
||||||
private String title;
|
private String title;
|
||||||
|
private String color;
|
||||||
|
private String notes;
|
||||||
|
private Integer priority;
|
||||||
private LocalDateTime createdAt;
|
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 jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -39,7 +40,24 @@ public class BookMarkEntity {
|
|||||||
@Column(name = "title")
|
@Column(name = "title")
|
||||||
private String 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
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,17 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface BookMarkRepository extends JpaRepository<BookMarkEntity, Long> {
|
public interface BookMarkRepository extends JpaRepository<BookMarkEntity, Long> {
|
||||||
|
|
||||||
Optional<BookMarkEntity> findByIdAndUserId(Long id, Long userId);
|
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")
|
@Query("SELECT b FROM BookMarkEntity b WHERE b.bookId = :bookId AND b.userId = :userId ORDER BY b.priority ASC, b.createdAt DESC")
|
||||||
List<BookMarkEntity> findByBookIdAndUserIdOrderByCreatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId);
|
List<BookMarkEntity> findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId);
|
||||||
|
|
||||||
boolean existsByCfiAndBookIdAndUserId(String cfi, Long bookId, 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;
|
package com.adityachandel.booklore.service.book;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.config.BookmarkProperties;
|
||||||
import com.adityachandel.booklore.mapper.BookMarkMapper;
|
import com.adityachandel.booklore.mapper.BookMarkMapper;
|
||||||
import com.adityachandel.booklore.model.dto.BookMark;
|
import com.adityachandel.booklore.model.dto.BookMark;
|
||||||
import com.adityachandel.booklore.model.dto.CreateBookMarkRequest;
|
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.BookEntity;
|
||||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||||
import com.adityachandel.booklore.model.entity.BookMarkEntity;
|
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.BookRepository;
|
||||||
import com.adityachandel.booklore.repository.UserRepository;
|
import com.adityachandel.booklore.repository.UserRepository;
|
||||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||||
|
import com.adityachandel.booklore.exception.APIException;
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
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.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class BookMarkService {
|
public class BookMarkService {
|
||||||
|
|
||||||
private final BookMarkRepository bookMarkRepository;
|
private final BookMarkRepository bookMarkRepository;
|
||||||
private final BookRepository bookRepository;
|
private final BookRepository bookRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final BookMarkMapper mapper;
|
|
||||||
private final AuthenticationService authenticationService;
|
private final AuthenticationService authenticationService;
|
||||||
|
private final BookMarkMapper mapper;
|
||||||
|
private final BookmarkProperties bookmarkProperties;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<BookMark> getBookmarksForBook(Long bookId) {
|
public List<BookMark> getBookmarksForBook(Long bookId) {
|
||||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
Long userId = getCurrentUserId();
|
||||||
return bookMarkRepository.findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId)
|
return bookMarkRepository.findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(bookId, userId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(mapper::toDto)
|
.map(mapper::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public BookMark getBookmarkById(Long bookmarkId) {
|
||||||
|
return mapper.toDto(findBookmarkByIdAndUser(bookmarkId));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BookMark createBookmark(CreateBookMarkRequest request) {
|
public BookMark createBookmark(CreateBookMarkRequest request) {
|
||||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
Long userId = getCurrentUserId();
|
||||||
|
validateNoDuplicateBookmark(request.getCfi(), request.getBookId(), userId);
|
||||||
// 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()));
|
|
||||||
|
|
||||||
BookMarkEntity entity = BookMarkEntity.builder()
|
BookMarkEntity bookmark = BookMarkEntity.builder()
|
||||||
.user(currentUser)
|
|
||||||
.book(book)
|
|
||||||
.cfi(request.getCfi())
|
.cfi(request.getCfi())
|
||||||
.title(request.getTitle())
|
.title(request.getTitle())
|
||||||
|
.book(findBook(request.getBookId()))
|
||||||
|
.user(findUser(userId))
|
||||||
|
.priority(bookmarkProperties.getDefaultPriority())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
BookMarkEntity saved = bookMarkRepository.save(entity);
|
log.info("Creating bookmark for book {} by user {}", request.getBookId(), userId);
|
||||||
return mapper.toDto(saved);
|
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
|
@Transactional
|
||||||
public void deleteBookmark(Long bookmarkId) {
|
public void deleteBookmark(Long bookmarkId) {
|
||||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
BookMarkEntity bookmark = findBookmarkByIdAndUser(bookmarkId);
|
||||||
BookMarkEntity bookmark = bookMarkRepository.findByIdAndUserId(bookmarkId, userId)
|
log.info("Deleting bookmark {}", bookmarkId);
|
||||||
.orElseThrow(() -> new EntityNotFoundException("Bookmark not found: " + bookmarkId));
|
|
||||||
bookMarkRepository.delete(bookmark);
|
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;
|
package com.adityachandel.booklore.service;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.config.BookmarkProperties;
|
||||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||||
import com.adityachandel.booklore.mapper.BookMarkMapper;
|
import com.adityachandel.booklore.mapper.BookMarkMapper;
|
||||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||||
@@ -40,6 +41,8 @@ class BookMarkServiceTest {
|
|||||||
private BookMarkMapper mapper;
|
private BookMarkMapper mapper;
|
||||||
@Mock
|
@Mock
|
||||||
private AuthenticationService authenticationService;
|
private AuthenticationService authenticationService;
|
||||||
|
@Mock
|
||||||
|
private BookmarkProperties bookmarkProperties;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private BookMarkService bookMarkService;
|
private BookMarkService bookMarkService;
|
||||||
@@ -58,14 +61,14 @@ class BookMarkServiceTest {
|
|||||||
userDto = BookLoreUser.builder().id(userId).build();
|
userDto = BookLoreUser.builder().id(userId).build();
|
||||||
userEntity = BookLoreUserEntity.builder().id(userId).build();
|
userEntity = BookLoreUserEntity.builder().id(userId).build();
|
||||||
bookEntity = BookEntity.builder().id(bookId).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();
|
bookmarkDto = BookMark.builder().id(bookmarkId).bookId(bookId).cfi("cfi").title("title").build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getBookmarksForBook_Success() {
|
void getBookmarksForBook_Success() {
|
||||||
when(authenticationService.getAuthenticatedUser()).thenReturn(userDto);
|
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);
|
when(mapper.toDto(bookmarkEntity)).thenReturn(bookmarkDto);
|
||||||
|
|
||||||
List<BookMark> result = bookMarkService.getBookmarksForBook(bookId);
|
List<BookMark> result = bookMarkService.getBookmarksForBook(bookId);
|
||||||
@@ -73,7 +76,7 @@ class BookMarkServiceTest {
|
|||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
assertEquals(bookmarkId, result.get(0).getId());
|
assertEquals(bookmarkId, result.get(0).getId());
|
||||||
verify(bookMarkRepository).findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId);
|
verify(bookMarkRepository).findByBookIdAndUserIdOrderByPriorityAscCreatedAtDesc(bookId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -81,6 +84,7 @@ class BookMarkServiceTest {
|
|||||||
CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark");
|
CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark");
|
||||||
|
|
||||||
when(authenticationService.getAuthenticatedUser()).thenReturn(userDto);
|
when(authenticationService.getAuthenticatedUser()).thenReturn(userDto);
|
||||||
|
when(bookmarkProperties.getDefaultPriority()).thenReturn(3);
|
||||||
when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false);
|
when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(false);
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(userEntity));
|
when(userRepository.findById(userId)).thenReturn(Optional.of(userEntity));
|
||||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||||
@@ -94,16 +98,26 @@ class BookMarkServiceTest {
|
|||||||
verify(bookMarkRepository).save(any(BookMarkEntity.class));
|
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
|
@Test
|
||||||
void createBookmark_BookNotFound() {
|
void createBookmark_BookNotFound() {
|
||||||
CreateBookMarkRequest request = new CreateBookMarkRequest(bookId, "new-cfi", "New Bookmark");
|
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());
|
verify(bookMarkRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +139,60 @@ class BookMarkServiceTest {
|
|||||||
assertThrows(EntityNotFoundException.class, () -> bookMarkService.deleteBookmark(bookmarkId));
|
assertThrows(EntityNotFoundException.class, () -> bookMarkService.deleteBookmark(bookmarkId));
|
||||||
verify(bookMarkRepository, never()).delete(any());
|
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,72 +12,101 @@
|
|||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span class="progress-percentage"><span class="progress-label">Progress: </span>{{ progressPercentage }}%</span>
|
<span class="progress-percentage"><span class="progress-label">Progress: </span>{{ progressPercentage }}%</span>
|
||||||
</div>
|
</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">
|
<ng-template pTemplate="header">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<span class="drawer-title">Table of Contents</span>
|
<span class="drawer-title">Table of Contents</span>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<p-tabs value="0">
|
<p-tabs value="0">
|
||||||
<p-tablist>
|
<p-tablist>
|
||||||
<p-tab value="0">
|
<p-tab value="0">
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
<i class="pi pi-book"></i>
|
<i class="pi pi-book"></i>
|
||||||
<span>Chapters</span>
|
<span>Chapters</span>
|
||||||
|
</div>
|
||||||
|
</p-tab>
|
||||||
|
<p-tab value="1">
|
||||||
|
<div class="tab-header">
|
||||||
|
<i class="pi pi-bookmark"></i>
|
||||||
|
<span>Bookmarks</span>
|
||||||
|
</div>
|
||||||
|
</p-tab>
|
||||||
|
</p-tablist>
|
||||||
|
<p-tabpanels>
|
||||||
|
<p-tabpanel value="0">
|
||||||
|
<div class="tab-content">
|
||||||
|
<ul class="chapter-list">
|
||||||
|
@for (chapter of chapters; track chapter) {
|
||||||
|
<li (click)="navigateToChapter(chapter); $event.stopPropagation()"
|
||||||
|
class="chapter-item"
|
||||||
|
[class.current-chapter]="chapter.href === currentChapterHref"
|
||||||
|
[style.padding-left.rem]="chapter.level * 1.5 + 0.75">
|
||||||
|
<i class="pi pi-chevron-right chapter-icon"></i>
|
||||||
|
<span class="chapter-label">{{ chapter.label }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</p-tabpanel>
|
||||||
|
<p-tabpanel value="1">
|
||||||
|
<div class="tab-content">
|
||||||
|
<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 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 filteredBookmarks; track bookmark.id) {
|
||||||
|
<li class="bookmark-item" (click)="navigateToBookmark(bookmark); $event.stopPropagation()">
|
||||||
|
<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-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>
|
</div>
|
||||||
</p-tab>
|
</li>
|
||||||
<p-tab value="1">
|
}
|
||||||
<div class="tab-header">
|
</ul>
|
||||||
<i class="pi pi-bookmark"></i>
|
}
|
||||||
<span>Bookmarks</span>
|
</div>
|
||||||
</div>
|
</p-tabpanel>
|
||||||
</p-tab>
|
</p-tabpanels>
|
||||||
</p-tablist>
|
</p-tabs>
|
||||||
<p-tabpanels>
|
</p-drawer>
|
||||||
<p-tabpanel value="0">
|
|
||||||
<div class="tab-content">
|
|
||||||
<ul class="chapter-list">
|
|
||||||
@for (chapter of chapters; track chapter) {
|
|
||||||
<li (click)="navigateToChapter(chapter); $event.stopPropagation()"
|
|
||||||
class="chapter-item"
|
|
||||||
[class.current-chapter]="chapter.href === currentChapterHref"
|
|
||||||
[style.padding-left.rem]="chapter.level * 1.5 + 0.75">
|
|
||||||
<i class="pi pi-chevron-right chapter-icon"></i>
|
|
||||||
<span class="chapter-label">{{ chapter.label }}</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</p-tabpanel>
|
|
||||||
<p-tabpanel value="1">
|
|
||||||
<div class="tab-content">
|
|
||||||
@if (bookmarks.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>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<ul class="bookmark-list">
|
|
||||||
@for (bookmark of bookmarks; track bookmark.id) {
|
|
||||||
<li class="bookmark-item" (click)="navigateToBookmark(bookmark); $event.stopPropagation()">
|
|
||||||
<i class="pi pi-bookmark-fill bookmark-icon"></i>
|
|
||||||
<span class="bookmark-label">{{ bookmark.title }}</span>
|
|
||||||
<button
|
|
||||||
class="bookmark-delete"
|
|
||||||
(click)="deleteBookmark(bookmark.id); $event.stopPropagation()">
|
|
||||||
<i class="pi pi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</p-tabpanel>
|
|
||||||
</p-tabpanels>
|
|
||||||
</p-tabs>
|
|
||||||
</p-drawer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
@@ -268,3 +297,18 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmark-delete {
|
.bookmark-actions {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-action-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -318,9 +325,16 @@
|
|||||||
color: color-mix(in srgb, var(--text-color) 60%, transparent);
|
color: color-mix(in srgb, var(--text-color) 60%, transparent);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
&:hover {
|
&: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);
|
background: color-mix(in srgb, #ef4444 15%, transparent);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
@@ -334,7 +348,7 @@
|
|||||||
background-color: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
background-color: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
|
|
||||||
.bookmark-delete {
|
.bookmark-actions {
|
||||||
opacity: 1;
|
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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ePub from 'epubjs';
|
|||||||
import {Drawer} from 'primeng/drawer';
|
import {Drawer} from 'primeng/drawer';
|
||||||
import {forkJoin, Subscription} from 'rxjs';
|
import {forkJoin, Subscription} from 'rxjs';
|
||||||
import {Button} from 'primeng/button';
|
import {Button} from 'primeng/button';
|
||||||
|
import {InputText} from 'primeng/inputtext';
|
||||||
|
import {CommonModule} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {ActivatedRoute} from '@angular/router';
|
import {ActivatedRoute} from '@angular/router';
|
||||||
import {Book, BookSetting} from '../../../book/model/book.model';
|
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 {UserService} from '../../../settings/user-management/user.service';
|
||||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||||
import {MessageService, PrimeTemplate} from 'primeng/api';
|
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 {Tooltip} from 'primeng/tooltip';
|
||||||
import {Slider} from 'primeng/slider';
|
import {Slider} from 'primeng/slider';
|
||||||
import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper';
|
import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper';
|
||||||
import {EpubThemeUtil, EpubTheme} from '../epub-theme-util';
|
import {EpubThemeUtil, EpubTheme} from '../epub-theme-util';
|
||||||
import {PageTitleService} from "../../../../shared/service/page-title.service";
|
import {PageTitleService} from "../../../../shared/service/page-title.service";
|
||||||
import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs';
|
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({
|
@Component({
|
||||||
selector: 'app-epub-reader',
|
selector: 'app-epub-reader',
|
||||||
templateUrl: './epub-reader.component.html',
|
templateUrl: './epub-reader.component.html',
|
||||||
styleUrls: ['./epub-reader.component.scss'],
|
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
|
standalone: true
|
||||||
})
|
})
|
||||||
export class EpubReaderComponent implements OnInit, OnDestroy {
|
export class EpubReaderComponent implements OnInit, OnDestroy {
|
||||||
@@ -40,8 +66,15 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
|||||||
isBookmarked = false;
|
isBookmarked = false;
|
||||||
isAddingBookmark = false;
|
isAddingBookmark = false;
|
||||||
isDeletingBookmark = false;
|
isDeletingBookmark = false;
|
||||||
|
isEditingBookmark = false;
|
||||||
|
isUpdatingPosition = false;
|
||||||
private routeSubscription?: Subscription;
|
private routeSubscription?: Subscription;
|
||||||
|
|
||||||
|
// Bookmark Filter & View
|
||||||
|
filterText = '';
|
||||||
|
viewDialogVisible = false;
|
||||||
|
selectedBookmark: BookMark | null = null;
|
||||||
|
|
||||||
public locationsReady = false;
|
public locationsReady = false;
|
||||||
public approxProgress = 0;
|
public approxProgress = 0;
|
||||||
public exactProgress = 0;
|
public exactProgress = 0;
|
||||||
@@ -54,6 +87,10 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
|||||||
private isMouseInTopRegion = false;
|
private isMouseInTopRegion = false;
|
||||||
private headerShownByMobileTouch = false;
|
private headerShownByMobileTouch = false;
|
||||||
|
|
||||||
|
// Properties for bookmark editing
|
||||||
|
editingBookmark: BookMark | null = null;
|
||||||
|
showEditBookmarkDialog = false;
|
||||||
|
|
||||||
private book: any;
|
private book: any;
|
||||||
private rendition: any;
|
private rendition: any;
|
||||||
private keyListener: (e: KeyboardEvent) => void = () => {
|
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 {
|
updateThemeStyle(): void {
|
||||||
this.applyCombinedTheme();
|
this.applyCombinedTheme();
|
||||||
this.updateViewerSetting();
|
this.updateViewerSetting();
|
||||||
@@ -620,6 +690,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
|||||||
this.bookMarkService.createBookmark(request).subscribe({
|
this.bookMarkService.createBookmark(request).subscribe({
|
||||||
next: (bookmark) => {
|
next: (bookmark) => {
|
||||||
this.bookmarks.push(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.updateBookmarkStatus();
|
||||||
this.messageService.add({
|
this.messageService.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@@ -643,6 +715,11 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
|||||||
if (this.isDeletingBookmark) {
|
if (this.isDeletingBookmark) {
|
||||||
return;
|
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.isDeletingBookmark = true;
|
||||||
this.bookMarkService.deleteBookmark(bookmarkId).subscribe({
|
this.bookMarkService.deleteBookmark(bookmarkId).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -682,4 +759,85 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
|
|||||||
? this.bookmarks.some(b => b.cfi === this.currentCfi)
|
? this.bookmarks.some(b => b.cfi === this.currentCfi)
|
||||||
: false;
|
: 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 {
|
export interface BookMark {
|
||||||
id: number;
|
id: number;
|
||||||
|
userId?: number;
|
||||||
bookId: number;
|
bookId: number;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
notes?: string;
|
||||||
|
priority?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookMarkRequest {
|
export interface CreateBookMarkRequest {
|
||||||
@@ -17,6 +22,14 @@ export interface CreateBookMarkRequest {
|
|||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateBookMarkRequest {
|
||||||
|
title?: string;
|
||||||
|
cfi?: string;
|
||||||
|
color?: string;
|
||||||
|
notes?: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -36,4 +49,7 @@ export class BookMarkService {
|
|||||||
deleteBookmark(bookmarkId: number): Observable<void> {
|
deleteBookmark(bookmarkId: number): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.url}/${bookmarkId}`);
|
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