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:
Balázs Szücs
2025-12-21 06:22:10 +01:00
committed by GitHub
parent 1b4bdc2ddb
commit 645234e66f
18 changed files with 977 additions and 124 deletions

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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}")

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
CREATE INDEX idx_bookmark_book_user_priority
ON book_marks(book_id, user_id, priority, created_at);

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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';
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}
});
}
}

View File

@@ -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);
}
}