mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-02-09 11:14:37 -06:00
Support adding private, user-specific notes to books
This commit is contained in:
committed by
Aditya Chandel
parent
b8284b2e1c
commit
800cc4054f
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
42
booklore-ui/src/app/core/service/book-note.service.ts
Normal file
42
booklore-ui/src/app/core/service/book-note.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user