Support adding private, user-specific notes to books

This commit is contained in:
aditya.chandel
2025-08-13 15:54:11 -06:00
committed by Aditya Chandel
parent b8284b2e1c
commit 800cc4054f
19 changed files with 1345 additions and 94 deletions

View File

@@ -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<BookNote> getNotesForBook(@PathVariable Long bookId) {
return bookNoteService.getNotesForBook(bookId);
}
@PostMapping
public BookNote createNote(@Valid @RequestBody CreateBookNoteRequest request) {
return bookNoteService.createOrUpdateNote(request);
}
@DeleteMapping("/{noteId}")
public ResponseEntity<Void> deleteNote(@PathVariable Long noteId) {
bookNoteService.deleteNote(noteId);
return ResponseEntity.noContent().build();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<BookNoteEntity, Long> {
Optional<BookNoteEntity> 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<BookNoteEntity> findByBookIdAndUserIdOrderByUpdatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
<div class="book-notes-container">
<div class="notes-content">
@if (loading) {
<div class="flex flex-col items-center justify-center p-8 gap-4">
<p-progressSpinner/>
<span class="text-gray-400">Loading notes...</span>
</div>
} @else if (notes.length === 0) {
<div class="text-center p-8 text-gray-400 empty-state">
<div class="empty-state-icon">
<p-button
outlined
rounded
icon="pi pi-plus"
severity="primary"
(click)="openCreateDialog()"
pTooltip="Add New Note"
tooltipPosition="top"
class="action-btn floating-btn">
</p-button>
</div>
<p class="text-gray-200 empty-state-title">No notes yet for this book</p>
<p class="text-sm text-gray-400 mt-2 empty-state-subtitle">
Click "Add Note" to create your first note and start capturing your thoughts
</p>
<div class="empty-state-decoration"></div>
</div>
} @else {
<div class="notes-scroll-container">
<div class="notes-horizontal-list">
@for (note of notes; track note.id + '-' + note.createdAt + '-' + $index) {
<div class="note-card border border-zinc-700">
<div class="note-header">
<div class="flex justify-between items-start p-3">
<div class="flex flex-col gap-1">
<h4 class="note-title font-medium text-lg text-gray-200 m-0">{{ note.title }}</h4>
<div class="note-meta">
<i class="pi pi-clock text-xs mr-1"></i>
<span class="text-sm text-gray-400">
@if (note.createdAt !== note.updatedAt) {
Updated {{ formatDate(note.updatedAt) }}
} @else {
Created {{ formatDate(note.createdAt) }}
}
</span>
</div>
</div>
<div class="note-actions">
<p-button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
(onClick)="openEditDialog(note)"
pTooltip="Edit note"
tooltipPosition="top"
class="action-btn-hover">
</p-button>
<p-button
icon="pi pi-trash"
size="small"
severity="danger"
text
(onClick)="deleteNote(note)"
pTooltip="Delete note"
tooltipPosition="top"
class="action-btn-hover">
</p-button>
</div>
</div>
</div>
<div class="note-content-area px-3 pb-3">
<div class="text-zinc-300 note-body">{{ note.content }}</div>
<div class="note-fade-overlay"></div>
</div>
<div class="note-bottom-glow"></div>
</div>
}
</div>
</div>
}
</div>
@if (notes.length > 0) {
<div class="action-bar">
<p-button
outlined
icon="pi pi-plus"
severity="success"
(click)="openCreateDialog()"
pTooltip="Add New Note"
tooltipPosition="left">
</p-button>
</div>
}
<p-dialog
header="Add New Note"
[(visible)]="showCreateDialog"
[modal]="true"
[style]="{width: '500px'}"
[closable]="true"
[dismissableMask]="true">
<div class="flex flex-col gap-4">
<div class="field">
<label for="create-title" class="block text-sm font-medium mb-2">Title</label>
<input
id="create-title"
pInputText
[(ngModel)]="newNote.title"
placeholder="Enter note title"
class="w-full"
maxlength="255"/>
</div>
<div class="field">
<label for="create-content" class="block text-sm font-medium mb-2">Content</label>
<textarea
id="create-content"
pTextarea
[(ngModel)]="newNote.content"
placeholder="Enter your note content..."
[rows]="8"
class="w-full"
[autoResize]="true">
</textarea>
</div>
</div>
<ng-template #footer>
<div class="flex gap-2 justify-end">
<p-button
label="Cancel"
severity="secondary"
outlined
(onClick)="cancelCreate()">
</p-button>
<p-button
outlined
severity="success"
label="Save Note"
icon="pi pi-save"
(onClick)="createNote()">
</p-button>
</div>
</ng-template>
</p-dialog>
<p-dialog
header="Edit Note"
[(visible)]="showEditDialog"
[modal]="true"
[style]="{width: '500px'}"
[closable]="true"
[dismissableMask]="true">
<div class="flex flex-col gap-4">
<div class="field">
<label for="edit-title" class="block text-sm font-medium mb-2">Title</label>
<input
id="edit-title"
pInputText
[(ngModel)]="editNote.title"
placeholder="Enter note title"
class="w-full"
maxlength="255"/>
</div>
<div class="field">
<label for="edit-content" class="block text-sm font-medium mb-2">Content</label>
<textarea
id="edit-content"
pTextarea
[(ngModel)]="editNote.content"
placeholder="Enter your note content..."
[rows]="8"
class="w-full"
[autoResize]="true">
</textarea>
</div>
</div>
<ng-template #footer>
<div class="flex gap-2 justify-end">
<p-button
label="Cancel"
severity="secondary"
outlined
(onClick)="cancelEdit()">
</p-button>
<p-button
outlined
severity="success"
label="Update Note"
icon="pi pi-save"
(onClick)="updateNote()">
</p-button>
</div>
</ng-template>
</p-dialog>
<p-confirmDialog key="deleteNote"></p-confirmDialog>
</div>

View File

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

View File

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

View File

@@ -36,7 +36,10 @@
<div class="flex items-center gap-2">
<span class="font-medium text-sm reviewer-name">{{ review.reviewerName || 'Anonymous' }}</span>
@if (review.metadataProvider) {
<p-tag [rounded]="true" [value]="review.metadataProvider" severity="secondary"/>
<p-tag
[rounded]="true"
[value]="review.metadataProvider"
[severity]="getProviderSeverity(review.metadataProvider)"/>
}
@if (review.country) {
<p-tag [rounded]="true" [value]="review.country" severity="info"/>
@@ -45,9 +48,12 @@
<p-tag [rounded]="true" value="Spoiler" severity="warn" icon="pi pi-exclamation-triangle"/>
}
</div>
@if (review.date) {
<span class="text-sm text-gray-400">{{ formatDate(review.date) }}</span>
}
<div class="review-meta">
<i class="pi pi-clock text-xs mr-1"></i>
@if (review.date) {
<span class="text-sm text-gray-400">{{ formatDate(review.date) }}</span>
}
</div>
</div>
<div class="flex items-center gap-2">
@@ -61,7 +67,7 @@
</div>
}
@if (canDeleteReviews) {
@if (hasPermission) {
@if (reviewsLocked) {
<div class="flex items-center justify-center w-8 h-8">
<i class="pi pi-lock text-amber-500 text-sm"
@@ -76,7 +82,8 @@
text
(onClick)="deleteReview(review)"
pTooltip="Delete Review"
tooltipPosition="top"/>
tooltipPosition="top"
class="action-btn-hover"/>
}
}
</div>
@@ -84,7 +91,7 @@
</div>
@if (review.title && (!review.spoiler || isSpoilerRevealed(review.id!))) {
<div class="review-title-section px-3 pb-2">
<div class="review-title-section">
<h4 class="font-medium text-xl text-gray-200 leading-tight m-0 review-title"
[pTooltip]="review.title"
tooltipPosition="top">{{ review.title }}
@@ -92,7 +99,7 @@
</div>
}
<div class="review-content-area px-3 pb-2">
<div class="review-content-area">
@if (review.spoiler && !isSpoilerRevealed(review.id!)) {
<div class="spoiler-blur-content">
@if (review.title) {
@@ -111,7 +118,9 @@
<div class="text-zinc-400 italic text-sm">No review content available</div>
}
}
<div class="review-fade-overlay"></div>
</div>
<div class="review-bottom-glow"></div>
</div>
}
</div>
@@ -121,30 +130,33 @@
<div class="action-bar">
<div class="action-buttons">
<p-button
outlined
[icon]="reviewsLocked ? 'pi pi-lock' : 'pi pi-lock-open'"
size="small"
[severity]="reviewsLocked ? 'danger' : 'success'"
[disabled]="!reviewDownloadEnabled || loading"
(click)="toggleReviewsLock()"
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Unlock Reviews' : 'Lock Reviews'))"
tooltipPosition="left"
class="action-btn"/>
<p-button
outlined
icon="pi pi-refresh"
size="small"
severity="primary"
[loading]="loading"
[disabled]="reviewsLocked || !reviewDownloadEnabled || loading"
(click)="fetchNewReviews()"
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Reviews are locked' : 'Fetch New Reviews'))"
tooltipPosition="left"
class="action-btn"/>
@if (hasPermission) {
<p-button
outlined
[icon]="reviewsLocked ? 'pi pi-lock' : 'pi pi-lock-open'"
size="small"
[severity]="reviewsLocked ? 'danger' : 'success'"
[disabled]="!reviewDownloadEnabled || loading"
(click)="toggleReviewsLock()"
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Unlock Reviews' : 'Lock Reviews'))"
tooltipPosition="left"
class="action-btn"/>
@if (canDeleteReviews && reviews && reviews.length > 0) {
<p-button
outlined
icon="pi pi-refresh"
size="small"
severity="primary"
[loading]="loading"
[disabled]="reviewsLocked || !reviewDownloadEnabled || loading"
(click)="fetchNewReviews()"
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Reviews are locked' : 'Fetch New Reviews'))"
tooltipPosition="left"
class="action-btn"/>
}
@if (hasPermission && reviews && reviews.length > 0) {
<p-button
outlined
icon="pi pi-trash"

View File

@@ -26,7 +26,7 @@
flex-direction: column;
gap: 8px;
padding: 16px 8px;
background: rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.1);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
@@ -89,19 +89,33 @@
width: 500px;
height: 250px;
flex-shrink: 0;
padding: 8px;
border-radius: 12px;
padding: 0;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
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 {
border-color: var(--primary-color);
transition: border-color 0.2s ease;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: color-mix(in srgb, var(--primary-color), transparent 50%);
.action-btn-hover {
opacity: 1;
transform: scale(1);
}
}
&.no-title {
@@ -130,12 +144,50 @@
.review-header {
flex-shrink: 0;
border-bottom: 1px solid var(--surface-border);
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;
.reviewer-name {
max-width: 120px;
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;
}
.review-meta {
display: flex;
align-items: center;
opacity: 0.8;
i {
color: #3b82f6;
}
}
.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);
}
}
}
}
.review-title-section {
flex-shrink: 0;
border-bottom: 1px solid var(--surface-border);
padding: 8px 16px 4px 16px;
}
.review-content-area {
@@ -144,6 +196,17 @@
display: flex;
flex-direction: column;
position: relative;
padding: 8px 16px 16px 16px;
.review-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;
}
}
}
@@ -154,7 +217,6 @@
overflow-y: auto;
word-wrap: break-word;
white-space: pre-wrap;
padding-right: 8px;
box-sizing: border-box;
&::-webkit-scrollbar {
@@ -176,13 +238,6 @@
}
}
.reviewer-name {
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.review-title {
max-width: 450px;
white-space: nowrap;
@@ -190,41 +245,3 @@
text-overflow: ellipsis;
cursor: help;
}
/* For Firefox */
.reviews-scroll-container {
scrollbar-width: thin;
scrollbar-color: var(--surface-border) var(--surface-ground);
}
.review-body {
scrollbar-width: thin;
scrollbar-color: var(--surface-border) var(--surface-ground);
}
.spoiler-overlay-button {
position: absolute !important;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
pointer-events: auto;
}
.spoiler-blur-content {
pointer-events: none;
user-select: none;
flex: 1;
overflow: hidden;
width: 100%;
max-height: 210px;
display: flex;
flex-direction: column;
.review-body {
flex: 1;
height: auto;
max-height: 210px;
overflow: hidden;
}
}

View File

@@ -1,17 +1,17 @@
import {Component, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core';
import {CommonModule} from '@angular/common';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {BookReview, BookReviewService} from '../../book-review-service';
import {BookReview, BookReviewService} from '../../../book-review-service';
import {ProgressSpinner} from 'primeng/progressspinner';
import {Rating} from 'primeng/rating';
import {Tag} from 'primeng/tag';
import {Button} from 'primeng/button';
import {ConfirmationService, MessageService} from 'primeng/api';
import {UserService} from '../../settings/user-management/user.service';
import {UserService} from '../../../settings/user-management/user.service';
import {FormsModule} from '@angular/forms';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../book/service/book.service';
import {AppSettingsService} from '../../core/service/app-settings.service';
import {BookService} from '../../service/book.service';
import {AppSettingsService} from '../../../core/service/app-settings.service';
@Component({
selector: 'app-book-reviews',
@@ -34,7 +34,7 @@ export class BookReviewsComponent implements OnInit, OnChanges {
private destroyRef = inject(DestroyRef);
loading = false;
canDeleteReviews = false;
hasPermission = false;
revealedSpoilers = new Set<number>();
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';
}
}
}

View File

@@ -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<BookNote[]> {
return this.http.get<BookNote[]>(`${this.url}/book/${bookId}`);
}
createOrUpdateNote(request: CreateBookNoteRequest): Observable<BookNote> {
return this.http.post<BookNote>(this.url, request);
}
deleteNote(noteId: number): Observable<void> {
return this.http.delete<void>(`${this.url}/${noteId}`);
}
}

View File

@@ -497,6 +497,9 @@
<i class="pi pi-bookmark"></i> Similar Books
</p-tab>
<p-tab [value]="3">
<i class="pi pi-pen-to-square"></i> Notes
</p-tab>
<p-tab [value]="4">
<i class="pi pi-comments"></i> Reviews
</p-tab>
</p-tablist>
@@ -540,6 +543,9 @@
</div>
</p-tabpanel>
<p-tabpanel [value]="3">
<app-book-notes-component [bookId]="book.id"></app-book-notes-component>
</p-tabpanel>
<p-tabpanel [value]="4">
<app-book-reviews [bookId]="book.id"></app-book-reviews>
</p-tabpanel>
</p-tabpanels>

View File

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