diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookNoteController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookNoteController.java new file mode 100644 index 000000000..db3e367ad --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookNoteController.java @@ -0,0 +1,36 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.BookNote; +import com.adityachandel.booklore.model.dto.CreateBookNoteRequest; +import com.adityachandel.booklore.service.BookNoteService; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/book-notes") +@AllArgsConstructor +public class BookNoteController { + + private final BookNoteService bookNoteService; + + + @GetMapping("/book/{bookId}") + public List getNotesForBook(@PathVariable Long bookId) { + return bookNoteService.getNotesForBook(bookId); + } + + @PostMapping + public BookNote createNote(@Valid @RequestBody CreateBookNoteRequest request) { + return bookNoteService.createOrUpdateNote(request); + } + + @DeleteMapping("/{noteId}") + public ResponseEntity deleteNote(@PathVariable Long noteId) { + bookNoteService.deleteNote(noteId); + return ResponseEntity.noContent().build(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookNoteMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookNoteMapper.java new file mode 100644 index 000000000..96e398ac2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookNoteMapper.java @@ -0,0 +1,21 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.BookNote; +import com.adityachandel.booklore.model.entity.BookNoteEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface BookNoteMapper { + + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "bookId", source = "book.id") + BookNote toDto(BookNoteEntity entity); + + @Mapping(target = "user", ignore = true) + @Mapping(target = "book", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + BookNoteEntity toEntity(BookNote dto); +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookNote.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookNote.java new file mode 100644 index 000000000..50386df19 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookNote.java @@ -0,0 +1,23 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookNote { + private Long id; + private Long userId; + private Long bookId; + private String title; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/CreateBookNoteRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/CreateBookNoteRequest.java new file mode 100644 index 000000000..95fad8915 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/CreateBookNoteRequest.java @@ -0,0 +1,24 @@ +package com.adityachandel.booklore.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateBookNoteRequest { + private Long id; + + @NotNull(message = "Book ID is required") + private Long bookId; + + private String title; + + @NotBlank(message = "Content is required") + private String content; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookNoteEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookNoteEntity.java new file mode 100644 index 000000000..c919a7dfa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookNoteEntity.java @@ -0,0 +1,50 @@ +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; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "book_notes") +public class BookNoteEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private BookLoreUserEntity user; + + @Column(name = "user_id", insertable = false, updatable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id", nullable = false) + private BookEntity book; + + @Column(name = "book_id", insertable = false, updatable = false) + private Long bookId; + + @Column(name = "title") + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserSettingEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserSettingEntity.java index c51c6cc3d..0af67fb87 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserSettingEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserSettingEntity.java @@ -13,8 +13,7 @@ import java.time.LocalDateTime; @AllArgsConstructor @NoArgsConstructor @Entity -@Table(name = "user_settings", - uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "setting_key"})}) +@Table(name = "user_settings", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "setting_key"})}) public class UserSettingEntity { @Id diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookNoteRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookNoteRepository.java new file mode 100644 index 000000000..71f64259d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookNoteRepository.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.BookNoteEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface BookNoteRepository extends JpaRepository { + + Optional findByIdAndUserId(Long id, Long userId); + + @Query("SELECT n FROM BookNoteEntity n WHERE n.bookId = :bookId AND n.userId = :userId ORDER BY n.updatedAt DESC") + List findByBookIdAndUserIdOrderByUpdatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookNoteService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookNoteService.java new file mode 100644 index 000000000..2bdc1d015 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookNoteService.java @@ -0,0 +1,78 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.AuthenticationService; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.BookNoteMapper; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.BookNote; +import com.adityachandel.booklore.model.dto.CreateBookNoteRequest; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.BookNoteEntity; +import com.adityachandel.booklore.repository.BookNoteRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@AllArgsConstructor +public class BookNoteService { + + private final BookNoteRepository bookNoteRepository; + private final BookRepository bookRepository; + private final UserRepository userRepository; + private final BookNoteMapper mapper; + private final AuthenticationService authenticationService; + + @Transactional(readOnly = true) + public List getNotesForBook(Long bookId) { + BookLoreUser currentUser = authenticationService.getAuthenticatedUser(); + return bookNoteRepository.findByBookIdAndUserIdOrderByUpdatedAtDesc(bookId, currentUser.getId()) + .stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional + public BookNote createOrUpdateNote(CreateBookNoteRequest request) { + BookLoreUser currentUser = authenticationService.getAuthenticatedUser(); + + BookEntity book = bookRepository.findById(request.getBookId()) + .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); + + BookLoreUserEntity user = userRepository.findById(currentUser.getId()) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + currentUser.getId())); + + BookNoteEntity noteEntity; + + if (request.getId() != null) { + noteEntity = bookNoteRepository.findByIdAndUserId(request.getId(), currentUser.getId()) + .orElseThrow(() -> new EntityNotFoundException("Note not found: " + request.getId())); + noteEntity.setTitle(request.getTitle()); + noteEntity.setContent(request.getContent()); + } else { + noteEntity = BookNoteEntity.builder() + .user(user) + .book(book) + .title(request.getTitle()) + .content(request.getContent()) + .build(); + } + + BookNoteEntity savedNote = bookNoteRepository.save(noteEntity); + return mapper.toDto(savedNote); + } + + @Transactional + public void deleteNote(Long noteId) { + BookLoreUser currentUser = authenticationService.getAuthenticatedUser(); + BookNoteEntity note = bookNoteRepository.findByIdAndUserId(noteId, currentUser.getId()).orElseThrow(() -> new EntityNotFoundException("Note not found: " + noteId)); + bookNoteRepository.delete(note); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V47__Create_book_notes_table.sql b/booklore-api/src/main/resources/db/migration/V47__Create_book_notes_table.sql new file mode 100644 index 000000000..14f7eb740 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V47__Create_book_notes_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE book_notes +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + book_id BIGINT NOT NULL, + title VARCHAR(255), + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_book_notes_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_book_notes_book_id FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE +); + +CREATE INDEX idx_book_notes_user_id ON book_notes (user_id); +CREATE INDEX idx_book_notes_book_id ON book_notes (book_id); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookNoteServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookNoteServiceTest.java new file mode 100644 index 000000000..47137aabb --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookNoteServiceTest.java @@ -0,0 +1,187 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.AuthenticationService; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.BookNoteMapper; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.BookNote; +import com.adityachandel.booklore.model.dto.CreateBookNoteRequest; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.BookNoteEntity; +import com.adityachandel.booklore.repository.BookNoteRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BookNoteServiceTest { + + private BookNoteRepository bookNoteRepository; + private BookRepository bookRepository; + private UserRepository userRepository; + private BookNoteMapper mapper; + private BookNoteService service; + + private final Long userId = 1L; + private final Long bookId = 2L; + private final Long noteId = 3L; + + @BeforeEach + void setUp() { + bookNoteRepository = mock(BookNoteRepository.class); + bookRepository = mock(BookRepository.class); + userRepository = mock(UserRepository.class); + mapper = mock(BookNoteMapper.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + service = new BookNoteService(bookNoteRepository, bookRepository, userRepository, mapper, authenticationService); + + BookLoreUser user = new BookLoreUser(); + user.setId(userId); + when(authenticationService.getAuthenticatedUser()).thenReturn(user); + } + + @Test + void getNotesForBook_returnsMappedNotes() { + BookNoteEntity entity = BookNoteEntity.builder().id(noteId).build(); + BookNote dto = BookNote.builder().id(noteId).build(); + when(bookNoteRepository.findByBookIdAndUserIdOrderByUpdatedAtDesc(bookId, userId)) + .thenReturn(Collections.singletonList(entity)); + when(mapper.toDto(entity)).thenReturn(dto); + + List result = service.getNotesForBook(bookId); + + assertEquals(1, result.size()); + assertEquals(noteId, result.getFirst().getId()); + } + + @Test + void createOrUpdateNote_createsNewNote_whenIdIsNull() { + CreateBookNoteRequest req = CreateBookNoteRequest.builder() + .bookId(bookId) + .title("t") + .content("c") + .build(); + + BookEntity book = BookEntity.builder().id(bookId).build(); + BookLoreUserEntity userEntity = BookLoreUserEntity.builder().id(userId).build(); + BookNoteEntity savedEntity = BookNoteEntity.builder().id(noteId).build(); + BookNote dto = BookNote.builder().id(noteId).build(); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + when(userRepository.findById(userId)).thenReturn(Optional.of(userEntity)); + when(bookNoteRepository.save(any(BookNoteEntity.class))).thenReturn(savedEntity); + when(mapper.toDto(savedEntity)).thenReturn(dto); + + BookNote result = service.createOrUpdateNote(req); + + assertEquals(noteId, result.getId()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BookNoteEntity.class); + verify(bookNoteRepository).save(captor.capture()); + BookNoteEntity entity = captor.getValue(); + assertEquals(book, entity.getBook()); + assertEquals(userEntity, entity.getUser()); + assertEquals("t", entity.getTitle()); + assertEquals("c", entity.getContent()); + } + + @Test + void createOrUpdateNote_updatesExistingNote_whenIdIsPresent() { + CreateBookNoteRequest req = CreateBookNoteRequest.builder() + .id(noteId) + .bookId(bookId) + .title("new title") + .content("new content") + .build(); + + BookNoteEntity existing = BookNoteEntity.builder().id(noteId).title("old").content("old").build(); + BookNoteEntity saved = BookNoteEntity.builder().id(noteId).title("new title").content("new content").build(); + BookNote dto = BookNote.builder().id(noteId).title("new title").content("new content").build(); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(BookEntity.builder().id(bookId).build())); + when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).build())); + when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existing)); + when(bookNoteRepository.save(existing)).thenReturn(saved); + when(mapper.toDto(saved)).thenReturn(dto); + + BookNote result = service.createOrUpdateNote(req); + + assertEquals(noteId, result.getId()); + assertEquals("new title", existing.getTitle()); + assertEquals("new content", existing.getContent()); + } + + @Test + void createOrUpdateNote_throwsIfBookNotFound() { + CreateBookNoteRequest req = CreateBookNoteRequest.builder() + .bookId(bookId) + .title("t") + .content("c") + .build(); + + when(bookRepository.findById(bookId)).thenReturn(Optional.empty()); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> service.createOrUpdateNote(req)); + + assertTrue( + ex.getMessage().contains("BOOK_NOT_FOUND") || ex.getMessage().contains(String.valueOf(bookId)), + "Exception message should contain 'BOOK_NOT_FOUND' or the book id" + ); + } + + @Test + void createOrUpdateNote_throwsIfUserNotFound() { + CreateBookNoteRequest req = CreateBookNoteRequest.builder() + .bookId(bookId) + .title("t") + .content("c") + .build(); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(BookEntity.builder().id(bookId).build())); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> service.createOrUpdateNote(req)); + } + + @Test + void createOrUpdateNote_throwsIfNoteNotFoundForUpdate() { + CreateBookNoteRequest req = CreateBookNoteRequest.builder() + .id(noteId) + .bookId(bookId) + .title("t") + .content("c") + .build(); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(BookEntity.builder().id(bookId).build())); + when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).build())); + when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> service.createOrUpdateNote(req)); + } + + @Test + void deleteNote_deletesIfExists() { + BookNoteEntity entity = BookNoteEntity.builder().id(noteId).build(); + when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(entity)); + + service.deleteNote(noteId); + + verify(bookNoteRepository).delete(entity); + } + + @Test + void deleteNote_throwsIfNotFound() { + when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + assertThrows(EntityNotFoundException.class, () -> service.deleteNote(noteId)); + } +} diff --git a/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.html b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.html new file mode 100644 index 000000000..9495290da --- /dev/null +++ b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.html @@ -0,0 +1,206 @@ +
+
+ @if (loading) { +
+ + Loading notes... +
+ } @else if (notes.length === 0) { +
+
+ + +
+

No notes yet for this book

+

+ Click "Add Note" to create your first note and start capturing your thoughts +

+
+
+ } @else { +
+
+ @for (note of notes; track note.id + '-' + note.createdAt + '-' + $index) { +
+
+
+
+

{{ note.title }}

+
+ + + @if (note.createdAt !== note.updatedAt) { + Updated {{ formatDate(note.updatedAt) }} + } @else { + Created {{ formatDate(note.createdAt) }} + } + +
+
+ +
+ + + + +
+
+
+ +
+
{{ note.content }}
+
+
+
+
+ } +
+
+ } +
+ + @if (notes.length > 0) { +
+ + +
+ } + + + +
+
+ + +
+ +
+ + +
+
+ + +
+ + + + +
+
+
+ + + +
+
+ + +
+ +
+ + +
+
+ + +
+ + + + +
+
+
+ + +
diff --git a/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.scss b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.scss new file mode 100644 index 000000000..1ad32a5ef --- /dev/null +++ b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.scss @@ -0,0 +1,254 @@ +.book-notes-container { + width: 100%; + height: 100%; + position: relative; + display: flex; + min-height: 250px; + + .notes-content { + flex: 1; + height: 100%; + overflow: hidden; + } + + .action-bar { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + z-index: 1000; + } +} + +.empty-state { + position: relative; + padding: 3rem !important; + + .empty-state-title { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; + } + + .empty-state-subtitle { + max-width: 400px; + margin: 0 auto; + line-height: 1.6; + } + + .empty-state-decoration { + position: absolute; + top: 20%; + right: 15%; + width: 100px; + height: 100px; + border-radius: 50%; + filter: blur(40px); + animation: float 4s ease-in-out infinite; + } +} + +.notes-scroll-container { + overflow-x: auto; + overflow-y: hidden; + height: 100%; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: rgba(30, 30, 30, 0.5); + border-radius: 10px; + } + + scrollbar-width: thin; +} + +.notes-horizontal-list { + display: flex; + gap: 1.5rem; + padding: 0.5rem; +} + +.note-card { + width: 500px; + height: 240px; + flex-shrink: 0; + padding: 0; + border-radius: 16px; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + + @media (max-width: 768px) { + width: 340px; + height: 220px; + } + + @media (max-width: 480px) { + width: 290px; + height: 200px; + } + + &:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--primary-color), transparent 50%); + + .note-bottom-glow { + opacity: 1; + } + + .action-btn-hover { + opacity: 1; + transform: scale(1); + } + } + + .note-header { + flex-shrink: 0; + background: linear-gradient(135deg, rgba(40, 40, 40, 0.8), rgba(30, 30, 30, 0.8)); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + + .note-title { + max-width: 350px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: linear-gradient(135deg, #f8fafc, #e2e8f0); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + + @media (max-width: 768px) { + max-width: 200px; + font-size: 1rem; + } + + @media (max-width: 480px) { + max-width: 180px; + font-size: 0.9rem; + } + } + + .note-meta { + display: flex; + align-items: center; + opacity: 0.8; + + i { + color: #3b82f6; + } + } + + .note-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + + .action-btn-hover { + opacity: 0.7; + transition: all 0.3s ease; + transform: scale(0.9); + + ::ng-deep .p-button { + border-radius: 8px; + + &:hover { + transform: scale(1.1); + } + } + } + } + } + + .note-content-area { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + padding: 1rem !important; + + .note-fade-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 30px; + background: linear-gradient(transparent, rgba(20, 20, 20, 0.9)); + pointer-events: none; + } + } + + .note-bottom-glow { + position: absolute; + bottom: -5px; + left: 20%; + right: 20%; + height: 10px; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--primary-color), transparent 60%), + transparent + ); + border-radius: 50%; + opacity: 0; + transition: opacity 0.3s ease; + filter: blur(5px); + } +} + +.note-body { + flex: 1; + min-height: 0; + max-height: 180px; + overflow-y: auto; + word-wrap: break-word; + white-space: pre-wrap; + padding-right: 8px; + box-sizing: border-box; + line-height: 1.6; + color: #e2e8f0; + + @media (max-width: 768px) { + max-height: 140px; + font-size: 0.9rem; + } + + @media (max-width: 480px) { + max-height: 120px; + font-size: 0.85rem; + } + + &::-webkit-scrollbar { + width: 6px; + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.8; + } +} diff --git a/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.ts b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.ts new file mode 100644 index 000000000..c7036c01f --- /dev/null +++ b/booklore-ui/src/app/book/components/book-notes-component/book-notes-component.ts @@ -0,0 +1,253 @@ +import {Component, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {Button} from 'primeng/button'; +import {Dialog} from 'primeng/dialog'; +import {InputText} from 'primeng/inputtext'; +import {Textarea} from 'primeng/textarea'; +import {ConfirmDialog} from 'primeng/confirmdialog'; +import {ProgressSpinner} from 'primeng/progressspinner'; +import {Tooltip} from 'primeng/tooltip'; +import {ConfirmationService, MessageService} from 'primeng/api'; +import {BookNote, BookNoteService, CreateBookNoteRequest} from '../../../core/service/book-note.service'; + +@Component({ + selector: 'app-book-notes-component', + standalone: true, + imports: [ + CommonModule, + FormsModule, + Button, + Dialog, + InputText, + Textarea, + ConfirmDialog, + ProgressSpinner, + Tooltip + ], + templateUrl: './book-notes-component.html', + styleUrl: './book-notes-component.scss' +}) +export class BookNotesComponent implements OnInit, OnChanges { + @Input() bookId!: number; + + private bookNoteService = inject(BookNoteService); + private confirmationService = inject(ConfirmationService); + private messageService = inject(MessageService); + private destroyRef = inject(DestroyRef); + + notes: BookNote[] = []; + loading = false; + showCreateDialog = false; + showEditDialog = false; + selectedNote: BookNote | null = null; + + newNote: CreateBookNoteRequest = { + bookId: 0, + title: '', + content: '' + }; + + editNote: CreateBookNoteRequest = { + bookId: 0, + title: '', + content: '' + }; + + ngOnInit(): void { + if (this.bookId) { + this.loadNotes(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['bookId'] && changes['bookId'].currentValue) { + this.loadNotes(); + } + } + + loadNotes(): void { + if (!this.bookId) return; + + this.loading = true; + this.bookNoteService.getNotesForBook(this.bookId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (notes) => { + this.notes = notes.sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + this.loading = false; + }, + error: (error) => { + console.error('Failed to load notes:', error); + this.loading = false; + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to load notes for this book.' + }); + } + }); + } + + openCreateDialog(): void { + this.newNote = { + bookId: this.bookId, + title: '', + content: '' + }; + this.showCreateDialog = true; + } + + openEditDialog(note: BookNote): void { + this.selectedNote = note; + this.editNote = { + id: note.id, + bookId: note.bookId, + title: note.title, + content: note.content + }; + this.showEditDialog = true; + } + + createNote(): void { + if (!this.newNote.title.trim() || !this.newNote.content.trim()) { + this.messageService.add({ + severity: 'warn', + summary: 'Validation Error', + detail: 'Both title and content are required.' + }); + return; + } + + this.bookNoteService.createOrUpdateNote(this.newNote) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (note) => { + this.notes.unshift(note); + this.showCreateDialog = false; + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Note created successfully.' + }); + }, + error: (error) => { + console.error('Failed to create note:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to create note.' + }); + } + }); + } + + updateNote(): void { + if (!this.editNote.title?.trim() || !this.editNote.content?.trim()) { + this.messageService.add({ + severity: 'warn', + summary: 'Validation Error', + detail: 'Both title and content are required.' + }); + return; + } + + this.bookNoteService.createOrUpdateNote(this.editNote) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updatedNote) => { + const index = this.notes.findIndex(n => n.id === this.selectedNote?.id); + if (index !== -1) { + this.notes[index] = updatedNote; + this.notes.sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } + this.showEditDialog = false; + this.selectedNote = null; + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Note updated successfully.' + }); + }, + error: (error) => { + console.error('Failed to update note:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update note.' + }); + } + }); + } + + deleteNote(note: BookNote): void { + this.confirmationService.confirm({ + key: 'deleteNote', + message: `Are you sure you want to delete the note "${note.title}"?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + acceptIcon: 'pi pi-trash', + rejectIcon: 'pi pi-times', + acceptButtonStyleClass: 'p-button-danger, p-button-outlined p-button-danger', + rejectButtonStyleClass: 'p-button-danger, p-button-outlined p-button-info', + accept: () => { + this.performDelete(note.id); + } + }); + } + + private performDelete(noteId: number): void { + this.bookNoteService.deleteNote(noteId).subscribe({ + next: () => { + this.notes = this.notes.filter(n => n.id !== noteId); + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Note deleted successfully.' + }); + }, + error: (error) => { + console.error('Failed to delete note:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to delete note.' + }); + } + }); + } + + cancelCreate(): void { + this.showCreateDialog = false; + this.newNote = { + bookId: this.bookId, + title: '', + content: '' + }; + } + + cancelEdit(): void { + this.showEditDialog = false; + this.selectedNote = null; + this.editNote = { + bookId: this.bookId, + title: '', + content: '' + }; + } + + formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } +} diff --git a/booklore-ui/src/app/components/book-reviews/book-reviews.component.html b/booklore-ui/src/app/book/components/book-reviews/book-reviews.component.html similarity index 75% rename from booklore-ui/src/app/components/book-reviews/book-reviews.component.html rename to booklore-ui/src/app/book/components/book-reviews/book-reviews.component.html index f3b84511b..2e4814858 100644 --- a/booklore-ui/src/app/components/book-reviews/book-reviews.component.html +++ b/booklore-ui/src/app/book/components/book-reviews/book-reviews.component.html @@ -36,7 +36,10 @@
{{ review.reviewerName || 'Anonymous' }} @if (review.metadataProvider) { - + } @if (review.country) { @@ -45,9 +48,12 @@ }
- @if (review.date) { - {{ formatDate(review.date) }} - } +
+ + @if (review.date) { + {{ formatDate(review.date) }} + } +
@@ -61,7 +67,7 @@
} - @if (canDeleteReviews) { + @if (hasPermission) { @if (reviewsLocked) {
+ tooltipPosition="top" + class="action-btn-hover"/> } }
@@ -84,7 +91,7 @@ @if (review.title && (!review.spoiler || isSpoilerRevealed(review.id!))) { -
+

{{ review.title }} @@ -92,7 +99,7 @@

} -
+
@if (review.spoiler && !isSpoilerRevealed(review.id!)) {
@if (review.title) { @@ -111,7 +118,9 @@
No review content available
} } +
+
}
@@ -121,30 +130,33 @@
- - + @if (hasPermission) { + - @if (canDeleteReviews && reviews && reviews.length > 0) { + + } + + @if (hasPermission && reviews && reviews.length > 0) { (); sortAscending = false; reviewsLocked = false; @@ -246,7 +246,7 @@ export class BookReviewsComponent implements OnInit, OnChanges { this.userService.userState$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(userState => { - this.canDeleteReviews = userState?.user?.permissions?.admin || + this.hasPermission = userState?.user?.permissions?.admin || userState?.user?.permissions?.canEditMetadata || false; }); } @@ -320,4 +320,13 @@ export class BookReviewsComponent implements OnInit, OnChanges { this.reviewDownloadEnabled = settings?.metadataPublicReviewsSettings?.downloadEnabled ?? true; }); } + + getProviderSeverity(provider: string): 'success' | 'warn' { + switch (provider?.toLowerCase()) { + case 'amazon': + return 'warn'; + default: + return 'success'; + } + } } diff --git a/booklore-ui/src/app/core/service/book-note.service.ts b/booklore-ui/src/app/core/service/book-note.service.ts new file mode 100644 index 000000000..bd37d5a46 --- /dev/null +++ b/booklore-ui/src/app/core/service/book-note.service.ts @@ -0,0 +1,42 @@ +import {Injectable, inject} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {API_CONFIG} from '../../config/api-config'; + +export interface BookNote { + id: number; + userId: number; + bookId: number; + title: string; + content: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateBookNoteRequest { + id?: number; + bookId: number; + title: string; + content: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class BookNoteService { + + private readonly url = `${API_CONFIG.BASE_URL}/api/v1/book-notes`; + private readonly http = inject(HttpClient); + + getNotesForBook(bookId: number): Observable { + return this.http.get(`${this.url}/book/${bookId}`); + } + + createOrUpdateNote(request: CreateBookNoteRequest): Observable { + return this.http.post(this.url, request); + } + + deleteNote(noteId: number): Observable { + return this.http.delete(`${this.url}/${noteId}`); + } +} diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index 848122d96..f41751816 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -497,6 +497,9 @@ Similar Books + Notes + + Reviews @@ -540,6 +543,9 @@
+ + + diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts index 40768b3b3..0339d0ef1 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts @@ -30,14 +30,15 @@ import {BookCardLiteComponent} from '../../../book/components/book-card-lite/boo import {ResetProgressType, ResetProgressTypes} from '../../../shared/constants/reset-progress-type'; import {DatePicker} from 'primeng/datepicker'; import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs'; -import {BookReviewsComponent} from '../../../components/book-reviews/book-reviews.component'; +import {BookReviewsComponent} from '../../../book/components/book-reviews/book-reviews.component'; +import {BookNotesComponent} from '../../../book/components/book-notes-component/book-notes-component'; @Component({ selector: 'app-metadata-viewer', standalone: true, templateUrl: './metadata-viewer.component.html', styleUrl: './metadata-viewer.component.scss', - imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent] + imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent] }) export class MetadataViewerComponent implements OnInit, OnChanges { @Input() book$!: Observable;