Websocket and stuffs

This commit is contained in:
aditya.chandel
2024-12-19 16:10:08 -07:00
parent 9e3b45fffc
commit e175a7e255
24 changed files with 569 additions and 496 deletions

View File

@@ -42,6 +42,8 @@ dependencies {
implementation 'com.github.jai-imageio:jai-imageio-jpeg2000:1.3.0'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.3'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2'
implementation 'org.springframework.boot:spring-boot-starter-websocket:3.4.0'
}
hibernate {

View File

@@ -0,0 +1,24 @@
package com.adityachandel.booklore.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*");
System.out.println("WebSocket endpoint registered at /ws");
}
}

View File

@@ -30,15 +30,11 @@ public class BookController {
}
@GetMapping
public ResponseEntity<Page<BookDTO>> getBooks(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "25") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "lastReadTime") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir) {
public ResponseEntity<List<BookDTO>> getBooks(@RequestParam(defaultValue = "lastReadTime") String sortBy, @RequestParam(defaultValue = "desc") String sortDir) {
if (!sortBy.equals("lastReadTime") && !sortBy.equals("addedOn")) {
return ResponseEntity.badRequest().body(null);
}
Page<BookDTO> books = booksService.getBooks(page, size, sortBy, sortDir);
List<BookDTO> books = booksService.getBooks(sortBy, sortDir);
return ResponseEntity.ok(books);
}

View File

@@ -14,6 +14,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/v1/library")
@@ -38,11 +41,6 @@ public class LibraryController {
return ResponseEntity.ok(libraryService.createLibrary(request));
}
@GetMapping(path = "/{libraryId}/parse")
public SseEmitter parseLibrary(@RequestParam(required = false, defaultValue = "false") boolean force, @PathVariable long libraryId) {
return libraryService.parseLibraryBooks(libraryId, force);
}
@DeleteMapping("/{libraryId}")
public ResponseEntity<?> deleteLibrary(@PathVariable long libraryId) {
libraryService.deleteLibrary(libraryId);
@@ -60,11 +58,8 @@ public class LibraryController {
}
@GetMapping("/{libraryId}/book")
public ResponseEntity<Page<BookDTO>> getBooks(
@PathVariable long libraryId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "25") @Min(1) @Max(100) int size) {
Page<BookDTO> books = libraryService.getBooks(libraryId, page, size);
public ResponseEntity<List<BookDTO>> getBooks(@PathVariable long libraryId) {
List<BookDTO> books = libraryService.getBooks(libraryId);
return ResponseEntity.ok(books);
}
}

View File

@@ -0,0 +1,34 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.service.NotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class NotificationController {
/*@MessageMapping("/send")
@SendTo("/topic/messages")
public String sendMessage(String message) {
System.out.println("Received message: " + message);
return "Hello";
}*/
private final NotificationService notificationService;
@Autowired
public NotificationController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@MessageMapping("/send")
public void handleIncomingMessage(Object message) {
System.out.println("Received message: " + message);
}
public void sendMessageToTopic(String topic, Object message) {
notificationService.sendMessage(topic, message);
}
}

View File

@@ -17,7 +17,7 @@ public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificat
Optional<Book> findByFileName(String fileName);
Page<Book> findBooksByLibraryId(Long libraryId, Pageable pageable);
List<Book> findBooksByLibraryId(Long libraryId);
Optional<Book> findBookByIdAndLibraryId(long id, long libraryId);

View File

@@ -49,6 +49,9 @@ public class BooksService {
private final AuthorRepository authorRepository;
private final CategoryRepository categoryRepository;
private final LibraryRepository libraryRepository;
private final NotificationService notificationService;
public BookDTO getBook(long bookId) {
@@ -56,19 +59,20 @@ public class BooksService {
return BookTransformer.convertToBookDTO(book);
}
public Page<BookDTO> getBooks(int page, int size, String sortBy, String sortDir) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDir), sortBy);
PageRequest pageRequest = PageRequest.of(page, size, sort);
Page<Book> bookPage = Page.empty();
public List<BookDTO> getBooks(String sortBy, String sortDir) {
int size = 25;
PageRequest pageRequest = PageRequest.of(0, size, Sort.by(Sort.Direction.fromString(sortDir), sortBy));
Page<Book> bookPage;
if (sortBy.equals("addedOn")) {
bookPage = bookRepository.findByAddedOnIsNotNull(pageRequest);
} else if (sortBy.equals("lastReadTime")) {
bookPage = bookRepository.findByLastReadTimeIsNotNull(pageRequest);
} else {
throw new IllegalArgumentException("Invalid sortBy parameter");
}
List<BookDTO> bookDTOs = bookPage.getContent().stream()
return bookPage.getContent().stream()
.map(BookTransformer::convertToBookDTO)
.collect(Collectors.toList());
return new PageImpl<>(bookDTOs, pageRequest, bookPage.getTotalElements());
}
public void saveBookViewerSetting(long bookId, BookViewerSettingDTO bookViewerSettingDTO) {

View File

@@ -2,7 +2,8 @@ package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.LibraryFile;
import com.adityachandel.booklore.model.dto.BookDTO;
public interface FileProcessor {
FileProcessResult processFile(LibraryFile libraryFile, boolean forceProcess);
BookDTO processFile(LibraryFile libraryFile, boolean forceProcess);
}

View File

@@ -0,0 +1,77 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.LibraryFile;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.entity.Library;
import com.adityachandel.booklore.repository.LibraryRepository;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@Service
@AllArgsConstructor
@Slf4j
public class LibraryProcessingService {
private final LibraryRepository libraryRepository;
private final NotificationService notificationService;
private final PdfFileProcessor pdfFileProcessor;
@Transactional
public void parseLibraryBooks(long libraryId) throws IOException {
Library library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
List<LibraryFile> libraryFiles = getLibraryFiles(library);
for (LibraryFile libraryFile : libraryFiles) {
log.info("Processing file: {}", libraryFile.getFilePath());
BookDTO bookDTO = processLibraryFile(libraryFile);
if(bookDTO != null) {
notificationService.sendMessage("/topic/books", bookDTO);
}
}
}
@Transactional
protected BookDTO processLibraryFile(LibraryFile libraryFile) {
if (libraryFile.getFileType().equalsIgnoreCase("pdf")) {
return pdfFileProcessor.processFile(libraryFile, false);
}
return null;
}
private List<LibraryFile> getLibraryFiles(Library library) throws IOException {
List<LibraryFile> libraryFiles = new ArrayList<>();
for (String libraryPath : library.getPaths()) {
libraryFiles.addAll(findLibraryFiles(libraryPath, library));
}
return libraryFiles;
}
private List<LibraryFile> findLibraryFiles(String directoryPath, Library library) throws IOException {
List<LibraryFile> libraryFiles = new ArrayList<>();
try (var stream = Files.walk(Path.of(directoryPath))) {
stream.filter(Files::isRegularFile)
.filter(file -> {
String fileName = file.getFileName().toString().toLowerCase();
return fileName.endsWith(".pdf") || fileName.endsWith(".epub");
})
.forEach(file -> {
String fileType = file.getFileName().toString().toLowerCase().endsWith(".pdf") ? "PDF" : "EPUB";
libraryFiles.add(new LibraryFile(library, file.toAbsolutePath().toString(), fileType));
});
}
return libraryFiles;
}
}

View File

@@ -1,37 +1,24 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.LibraryFile;
import com.adityachandel.booklore.model.ParseLibraryEvent;
import com.adityachandel.booklore.model.enums.ParsingStatus;
import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.dto.LibraryDTO;
import com.adityachandel.booklore.model.dto.request.CreateLibraryRequest;
import com.adityachandel.booklore.model.entity.Book;
import com.adityachandel.booklore.model.entity.Library;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.transformer.BookTransformer;
import com.adityachandel.booklore.transformer.LibraryTransformer;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
@Service
@@ -40,16 +27,29 @@ public class LibraryService {
private LibraryRepository libraryRepository;
private BookRepository bookRepository;
private PdfFileProcessor pdfFileProcessor;
private final Map<Long, ReentrantLock> libraryLocks = new ConcurrentHashMap<>();
private final LibraryProcessingService libraryProcessingService;
public LibraryDTO createLibrary(CreateLibraryRequest request) {
Library library = Library.builder()
.name(request.getName())
.paths(request.getPaths())
.build();
return LibraryTransformer.convertToLibraryDTO(libraryRepository.save(library));
library = libraryRepository.save(library);
Long libraryId = library.getId();
Thread.startVirtualThread(() -> {
log.info("Running in a virtual thread: {}", Thread.currentThread());
try {
libraryProcessingService.parseLibraryBooks(libraryId);
} catch (InvalidDataAccessApiUsageException e) {
log.warn("InvalidDataAccessApiUsageException - Library id: {}", libraryId);
} catch (IOException e) {
log.error("Error while parsing library books", e);
}
log.info("Parsing task completed!");
});
return LibraryTransformer.convertToLibraryDTO(library);
}
public LibraryDTO getLibrary(long libraryId) {
@@ -74,127 +74,9 @@ public class LibraryService {
return BookTransformer.convertToBookDTO(book);
}
public Page<BookDTO> getBooks(long libraryId, int page, int size) {
public List<BookDTO> getBooks(long libraryId) {
libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
PageRequest pageRequest = PageRequest.of(page, size);
Page<Book> bookPage = bookRepository.findBooksByLibraryId(libraryId, pageRequest);
return bookPage.map(BookTransformer::convertToBookDTO);
}
public SseEmitter parseLibraryBooks(long libraryId, boolean force) {
ReentrantLock lock = libraryLocks.computeIfAbsent(libraryId, k -> new ReentrantLock());
if (!lock.tryLock()) {
SseEmitter emitter = new SseEmitter();
emitter.completeWithError(new IllegalStateException("Library is already being processed"));
return emitter;
}
Library library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
if(library.isInitialProcessed()) {
SseEmitter emitter = new SseEmitter();
emitter.complete();
return emitter;
}
SseEmitter emitter = new SseEmitter();
ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor();
final AtomicBoolean isCompleted = new AtomicBoolean(false);
emitter.onTimeout(() -> {
log.info("Emitter timeout reached. Stopping further processing.");
emitter.complete();
isCompleted.set(true);
});
emitter.onCompletion(() -> {
log.info("Emitter completed. Shutting down executor.");
isCompleted.set(true);
sseMvcExecutor.shutdown();
});
sseMvcExecutor.execute(() -> {
try {
List<LibraryFile> libraryFiles = getLibraryFiles(library);
for (LibraryFile libraryFile : libraryFiles) {
try {
log.info("Processing file: {}", libraryFile.getFilePath());
FileProcessResult fileProcessResult = processLibraryFile(libraryFile);
ParseLibraryEvent event = createParseLibraryEvent(libraryId, fileProcessResult);
if (!isCompleted.get()) {
emitter.send(event);
}
Thread.sleep(500);
} catch (AsyncRequestNotUsableException | IllegalStateException e) {
log.warn("Client disconnected! Continue processing.");
} catch (IOException e) {
log.warn("Client disconnected or failed to send response: {}", libraryFile.getFilePath(), e);
emitter.completeWithError(e);
sseMvcExecutor.shutdown();
return;
} catch (Exception e) {
log.error("Error processing file: {}", libraryFile.getFilePath(), e);
emitter.completeWithError(e);
sseMvcExecutor.shutdown();
return;
}
}
library.setInitialProcessed(true);
libraryRepository.save(library);
emitter.complete();
sseMvcExecutor.shutdown();
} catch (Exception ex) {
log.error("Error during file processing: ", ex);
if (!isCompleted.get()) {
emitter.completeWithError(ex);
sseMvcExecutor.shutdown();
}
} finally {
log.info("File processing complete. Executor shutting down.");
lock.unlock();
emitter.complete();
sseMvcExecutor.shutdown();
}
});
return emitter;
}
private ParseLibraryEvent createParseLibraryEvent(long libraryId, FileProcessResult result) {
return ParseLibraryEvent.builder()
.libraryId(libraryId)
.file(result.getLibraryFile().getFilePath())
.parsingStatus(result.getParsingStatus())
.book(result.getBookDTO())
.build();
}
private FileProcessResult processLibraryFile(LibraryFile libraryFile) {
if (libraryFile.getFileType().equalsIgnoreCase("pdf")) {
return pdfFileProcessor.processFile(libraryFile, false);
} else if (libraryFile.getFileType().equalsIgnoreCase("epub")) {
// TODO:: To implement
return FileProcessResult.builder().parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK).libraryFile(libraryFile).build();
}
// TODO:: To handle
return FileProcessResult.builder().parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK).libraryFile(libraryFile).build();
}
private List<LibraryFile> getLibraryFiles(Library library) throws IOException {
List<LibraryFile> libraryFiles = new ArrayList<>();
for (String libraryPath : library.getPaths()) {
libraryFiles.addAll(findLibraryFiles(libraryPath, library));
}
return libraryFiles;
}
private List<LibraryFile> findLibraryFiles(String directoryPath, Library library) throws IOException {
List<LibraryFile> libraryFiles = new ArrayList<>();
try (var stream = Files.walk(Path.of(directoryPath))) {
stream.filter(Files::isRegularFile)
.filter(file -> {
String fileName = file.getFileName().toString().toLowerCase();
return fileName.endsWith(".pdf") || fileName.endsWith(".epub");
})
.forEach(file -> {
String fileType = file.getFileName().toString().toLowerCase().endsWith(".pdf") ? "PDF" : "EPUB";
libraryFiles.add(new LibraryFile(library, file.toAbsolutePath().toString(), fileType));
});
}
return libraryFiles;
List<Book> books = bookRepository.findBooksByLibraryId(libraryId);
return books.stream().map(BookTransformer::convertToBookDTO).toList();
}
}

View File

@@ -0,0 +1,18 @@
package com.adityachandel.booklore.service;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final SimpMessagingTemplate messagingTemplate;
public NotificationService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void sendMessage(String topic, Object message) {
messagingTemplate.convertAndSend(topic, message);
}
}

View File

@@ -1,13 +1,14 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.enums.ParsingStatus;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.LibraryFile;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.entity.Book;
import com.adityachandel.booklore.model.entity.BookMetadata;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.transformer.BookTransformer;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
@@ -15,6 +16,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.imageio.ImageIO;
@@ -22,7 +24,10 @@ import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@@ -34,32 +39,24 @@ public class PdfFileProcessor implements FileProcessor {
private BookCreatorService bookCreatorService;
private AppProperties appProperties;
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public FileProcessResult processFile(LibraryFile libraryFile, boolean forceProcess) {
public BookDTO processFile(LibraryFile libraryFile, boolean forceProcess) {
File bookFile = new File(libraryFile.getFilePath());
String fileName = bookFile.getName();
if (!forceProcess) {
Optional<Book> bookOptional = bookRepository.findBookByFileNameAndLibraryId(fileName, libraryFile.getLibrary().getId());
if (bookOptional.isPresent()) {
return FileProcessResult.builder()
.libraryFile(libraryFile)
.bookDTO(BookTransformer.convertToBookDTO(bookOptional.get()))
.parsingStatus(ParsingStatus.EXISTING_BOOK_NO_FORCED_UPDATE)
.build();
} else {
return processNewFile(libraryFile);
}
return bookOptional.map(BookTransformer::convertToBookDTO).orElseGet(() -> processNewFile(libraryFile));
} else {
return processNewFile(libraryFile);
}
}
private FileProcessResult processNewFile(LibraryFile libraryFile) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
protected BookDTO processNewFile(LibraryFile libraryFile) {
File bookFile = new File(libraryFile.getFilePath());
Book book = bookCreatorService.createShellBook(libraryFile);
BookMetadata bookMetadata = book.getMetadata();
FileProcessResult fileProcessResult;
try (PDDocument document = Loader.loadPDF(bookFile)) {
if (document.getDocumentInformation() == null) {
log.warn("No document information found");
@@ -74,19 +71,12 @@ public class PdfFileProcessor implements FileProcessor {
}
generateCoverImage(bookFile, new File(appProperties.getPathConfig() + "/thumbs"), document);
} catch (Exception e) {
log.error("Error while processing file {}", libraryFile.getFilePath(), e);
return FileProcessResult.builder()
.libraryFile(libraryFile)
.parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK)
.build();
log.error("Error while processing file {}, error: {}", libraryFile.getFilePath(), e.getMessage());
}
bookCreatorService.saveConnections(book);
fileProcessResult = FileProcessResult.builder()
.bookDTO(BookTransformer.convertToBookDTO(book))
.parsingStatus(ParsingStatus.PARSED_NEW_BOOK)
.libraryFile(libraryFile)
.build();
return fileProcessResult;
bookRepository.save(book);
bookRepository.flush();
return BookTransformer.convertToBookDTO(book);
}
private Set<String> getAuthors(PDDocument document) {

View File

@@ -1,11 +1,11 @@
{
"name": "my-library-v3",
"name": "booklore",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-library-v3",
"name": "booklore",
"version": "0.0.0",
"dependencies": {
"@angular/animations": "^19.0.0",
@@ -16,6 +16,10 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@iharbeck/ngx-virtual-scroller": "^19.0.1",
"@stomp/rx-stomp": "^2.0.0",
"@stomp/stompjs": "^7.0.0",
"ng-lazyload-image": "^9.1.3",
"ngx-extended-pdf-viewer": "^22.0.0",
"ngx-infinite-scroll": "^19.0.0",
"primeflex": "^3.3.1",
@@ -23,6 +27,7 @@
"primeng": "^17.18.12",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"ws": "^8.18.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
@@ -487,33 +492,6 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/localize": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.0.1.tgz",
"integrity": "sha512-tCfTOkdHj6VhskudgsNKF0SS/e0Le+9kv4tPdSsjo9bFcg806lG5/010+UYhy4MDJZ+vDUGTACrMRj2TcFmHRA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/core": "7.26.0",
"@types/babel__core": "7.20.5",
"fast-glob": "3.3.2",
"yargs": "^17.2.1"
},
"bin": {
"localize-extract": "tools/bundles/src/extract/cli.js",
"localize-migrate": "tools/bundles/src/migrate/cli.js",
"localize-translate": "tools/bundles/src/translate/cli.js"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/compiler": "19.0.1",
"@angular/compiler-cli": "19.0.1"
}
},
"node_modules/@angular/platform-browser": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.0.1.tgz",
@@ -2628,6 +2606,18 @@
"node": ">=18"
}
},
"node_modules/@iharbeck/ngx-virtual-scroller": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-19.0.1.tgz",
"integrity": "sha512-dtn4CpbEY92H9nd1A48WNhsyUgtFBjC83xcsc9VzlSQT/KN2fEx0oBs0Obnn6ZdPanDP/IQdlBgmANmlds/wHA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@tweenjs/tween.js": "^25.0.0"
}
},
"node_modules/@inquirer/checkbox": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.2.tgz",
@@ -4841,6 +4831,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stomp/rx-stomp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-2.0.0.tgz",
"integrity": "sha512-3UxTxAA3NWGnwFfIvN8AigJ7BxGXG0u5IK8K12mQ9cCMuaT/MM7xlyZnuV8sDbHiqqLlbwA1wk1fDfUyOTIeug==",
"license": "Apache-2.0",
"peerDependencies": {
"@stomp/stompjs": "^7.0.0",
"rxjs": "^7.2.0",
"uuid": "^9.0.0"
}
},
"node_modules/@stomp/stompjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz",
"integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==",
"license": "Apache-2.0"
},
"node_modules/@tufjs/canonical-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -4891,58 +4898,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.6.8",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
"integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.20.6",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
"integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.20.7"
}
"peer": true
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
@@ -7219,6 +7180,28 @@
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/enhanced-resolve": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
@@ -10633,6 +10616,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/ng-lazyload-image": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.3.tgz",
"integrity": "sha512-GlajmzbKhQCvg9pcrASq4fe/MNv9KoifGe6N+xRbseaBrNj2uwU4Vwic041NlmAQFEkpDM1H2EJCAjjmJeF7Hg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=11.0.0",
"@angular/core": ">=11.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/ngx-extended-pdf-viewer": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-22.0.0.tgz",
@@ -12921,6 +12918,28 @@
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
@@ -12947,6 +12966,16 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
@@ -14019,11 +14048,15 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -14825,28 +14858,6 @@
"node": ">=8.10.0"
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/webpack-merge": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz",
@@ -15105,10 +15116,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -18,6 +18,10 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@iharbeck/ngx-virtual-scroller": "^19.0.1",
"@stomp/rx-stomp": "^2.0.0",
"@stomp/stompjs": "^7.0.0",
"ng-lazyload-image": "^9.1.3",
"ngx-extended-pdf-viewer": "^22.0.0",
"ngx-infinite-scroll": "^19.0.0",
"primeflex": "^3.3.1",
@@ -25,6 +29,7 @@
"primeng": "^17.18.12",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"ws": "^8.18.0",
"zone.js": "~0.15.0"
},
"devDependencies": {

View File

@@ -1,5 +1,9 @@
import {Component, OnInit} from '@angular/core';
import {LibraryService} from './book/service/library.service';
import {RxStompService} from './rx-stomp.service';
import {Message} from '@stomp/stompjs';
import {BookService} from './book/service/book.service';
import {Book} from './book/model/book.model';
@Component({
selector: 'app-root',
@@ -9,9 +13,22 @@ import {LibraryService} from './book/service/library.service';
})
export class AppComponent implements OnInit {
constructor(private libraryService: LibraryService) {}
constructor(private libraryService: LibraryService, private bookService: BookService, private rxStompService: RxStompService) {
}
ngOnInit(): void {
this.libraryService.initializeLibraries();
this.bookService.loadBooksSignal(1);
this.rxStompService.watch('/topic/books').subscribe((message: Message) => {
const book: Book = JSON.parse(message.body); // Parse the incoming message to book DTO
this.bookService.appendBookToLibrary(book); // Call method to append to libraryBooks
});
}
sendMessage() {
const message = `Message generated at ${new Date()}`;
this.rxStompService.publish({destination: '/app/send', body: message});
}
}

View File

@@ -22,6 +22,10 @@ import {SearchComponent} from './book/component/search/search.component';
import {MessageService} from 'primeng/api';
import {FileUploadComponent} from './book/component/file-upload/file-upload.component';
import {DropdownModule} from 'primeng/dropdown';
import {rxStompServiceFactory} from './rx-stomp-service-factory';
import {RxStompService} from './rx-stomp.service';
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
import {LazyLoadImageModule} from 'ng-lazyload-image';
@NgModule({
declarations: [
@@ -46,8 +50,18 @@ import {DropdownModule} from 'primeng/dropdown';
InfiniteScrollDirective,
SearchComponent,
DropdownModule,
VirtualScrollerModule,
LazyLoadImageModule
],
providers: [
DialogService,
MessageService,
{
provide: RxStompService,
useFactory: rxStompServiceFactory,
},
],
providers: [DialogService, MessageService],
bootstrap: [AppComponent]
})
export class AppModule { }
export class AppModule {
}

View File

@@ -1,32 +1,38 @@
<div>
<div class="library-header">
<h3 class="library-name">{{ libraryNameSignal() }}</h3>
<div class="library-header-right">
<p-dropdown
[options]="cities"
[(ngModel)]="selectedCity"
optionLabel="name"
placeholder="Select Sorting" />
<div class="zoom-controls">
<p-button icon="pi pi-minus" [rounded]="true" [outlined]="true" (click)="decreaseSize()" [disabled]="isDecreaseDisabled()" severity="info"></p-button>
<p-button icon="pi pi-plus" [rounded]="true" [outlined]="true" (click)="increaseSize()" [disabled]="isIncreaseDisabled()" severity="info"></p-button>
<div class="library-header">
<h3 class="library-name">{{ libraryNameSignal() }}</h3>
<div class="library-header-right">
<p-dropdown
[options]="cities"
[(ngModel)]="selectedCity"
optionLabel="name"
placeholder="Select Sorting" />
<div class="zoom-controls">
<p-button icon="pi pi-minus" [rounded]="true" [outlined]="true" (click)="decreaseSize()" [disabled]="isDecreaseDisabled()" severity="info"></p-button>
<p-button icon="pi pi-plus" [rounded]="true" [outlined]="true" (click)="increaseSize()" [disabled]="isIncreaseDisabled()" severity="info"></p-button>
</div>
</div>
</div>
</div>
<div class="book-list"
infiniteScroll
[ngClass]="coverSizeClass"
[infiniteScrollDistance]="2"
[infiniteScrollThrottle]="50"
(scrolled)="loadBooks()">
<div class="book-item" *ngFor="let book of books">
<img [src]="coverImageSrc(book.id)" class="book-cover placeholder" alt="Cover of {{ book.metadata.title }}" loading="lazy"/>
<div class="book-info">
<h4 class="book-title">{{ book.metadata.title }}</h4>
<p class="book-authors">{{ getAuthorNames(book) }}</p>
<p-button [rounded]="true" icon="pi pi-eye" class="view-btn" (click)="readBook(book.id)"></p-button>
<p-button [rounded]="true" icon="pi pi-info" class="read-btn" (click)="openBookInfo(book.id, book.libraryId)"></p-button>
</div>
<div class="content-container">
<div class="card-container">
<virtual-scroller class="virtual-scroller" #scroll [items]="booksSignal()">
<div class="grid" #container>
<div class="virtual-scroller-item" *ngFor="let book of scroll.viewPortItems">
<!--<img [lazyLoad]="coverImageSrc(book.id)" class="book-cover placeholder"/>-->
<img [src]="coverImageSrc(book.id)" class="book-cover placeholder" alt="Cover of {{ book.metadata.title }}" loading="lazy"/>
<div class="book-info">
<h4 class="book-title">{{ book.metadata.title }}</h4>
<p-button [rounded]="true" icon="pi pi-eye" class="view-btn" (click)="readBook(book.id)"></p-button>
<p-button [rounded]="true" icon="pi pi-info" class="read-btn" (click)="openBookInfo(book.id, book.libraryId)"></p-button>
</div>
</div>
</div>
</virtual-scroller>
</div>
</div>
</div>

View File

@@ -23,62 +23,49 @@
align-items: center;
}
.book-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(135px, 1fr));
gap: 20px;
transition: all 0.3s ease-in-out;
.virtual-scroller {
width: 100%;
height: calc(100vh - 200px);
padding: 1rem;
box-sizing: border-box;
overflow-y: auto;
}
.small {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.medium {
grid-template-columns: repeat(auto-fill, minmax(135px, 1fr));
}
.large {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.extra-large {
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
}
.book-item {
.virtual-scroller-item {
background-color: var(--surface-card);
height: 240px;
width: 150px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1.3rem;
padding: 1rem;
width: 100%;
overflow: hidden;
align-items: start;
}
.book-cover {
width: 100%;
height: auto;
max-height: 100%;
object-fit: cover;
transition: filter 0.3s ease-in-out;
}
.book-item:hover .book-cover {
filter: blur(2px);
}
.book-info {
padding: 7px 7px 5px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.book-title {
font-size: 0.9rem;
font-size: 0.85rem;
color: var(--text-color);
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
padding: 6px 6px 5px;
}
.book-authors {
@@ -89,15 +76,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.authors {
font-weight: bold;
}
.no-authors {
color: #888;
font-style: italic;
text-align: center;
}
.view-btn, .read-btn {
@@ -110,15 +89,19 @@
transition: opacity 0.2s ease-in-out;
}
.book-item:hover .view-btn,
.book-item:hover .read-btn {
.virtual-scroller-item:hover .view-btn,
.virtual-scroller-item:hover .read-btn {
display: block;
}
.book-item:hover .read-btn {
.virtual-scroller-item:hover .read-btn {
top: 70%;
}
.infinite-scroll {
margin-top: 20px;
.virtual-scroller-item:hover .book-cover {
filter: blur(2px);
}

View File

@@ -1,33 +1,30 @@
import { Component, NgZone, OnDestroy, OnInit, signal, computed } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BookProgressService } from '../../service/book-progress-service';
import { BookService } from '../../service/book.service';
import { Book, BookUpdateEvent } from '../../model/book.model';
import { combineLatest, Subscription } from 'rxjs';
import { InfiniteScrollDirective } from 'ngx-infinite-scroll';
import { Button } from 'primeng/button';
import { NgClass, NgForOf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DropdownModule } from 'primeng/dropdown';
import { LibraryService } from '../../service/library.service';
import {Component, computed, OnInit, signal} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {BookService} from '../../service/book.service';
import {Book} from '../../model/book.model';
import {combineLatest} from 'rxjs';
import {Button} from 'primeng/button';
import {NgForOf} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {DropdownModule} from 'primeng/dropdown';
import {LibraryService} from '../../service/library.service';
import {LazyLoadImageModule} from 'ng-lazyload-image';
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
@Component({
selector: 'app-library-browser-v2',
templateUrl: './library-browser.component.html',
styleUrls: ['./library-browser.component.scss'],
imports: [
InfiniteScrollDirective,
Button,
NgForOf,
FormsModule,
DropdownModule,
NgClass
LazyLoadImageModule,
VirtualScrollerModule
]
})
export class LibraryBrowserComponent implements OnInit, OnDestroy {
books: Book[] = [];
private currentPage: number = 0;
private progressSubscription?: Subscription;
export class LibraryBrowserComponent implements OnInit {
coverSizeClass = 'medium';
coverSizeClasses = ['small', 'medium', 'large', 'extra-large'];
@@ -41,81 +38,25 @@ export class LibraryBrowserComponent implements OnInit, OnDestroy {
selectedCity: any;
cities: any[] | undefined;
booksSignal = computed(() => {
return this.bookService.libraryBooks();
});
constructor(
private bookService: BookService,
private activatedRoute: ActivatedRoute,
private router: Router,
private bookProgressService: BookProgressService,
private ngZone: NgZone,
private libraryService: LibraryService
) {}
private libraryService: LibraryService) {
}
ngOnInit(): void {
combineLatest([this.activatedRoute.paramMap, this.activatedRoute.queryParamMap])
.subscribe(([params, queryParams]) => {
const libraryId = params.get('libraryId');
const watch = queryParams.get('watch');
if (libraryId) {
const libraryIdNum = +libraryId;
if (this.libraryIdSignal() !== libraryIdNum) {
this.libraryIdSignal.set(libraryIdNum);
this.resetState();
this.loadBooks();
} else if (this.books.length === 0) {
this.loadBooks();
}
}
if (watch && !this.progressSubscription) {
this.startListeningForProgress();
} else if (!watch && this.progressSubscription) {
this.stopListeningForProgress();
}
});
}
startListeningForProgress(): void {
this.progressSubscription = this.bookProgressService
.connect(this.libraryIdSignal())
.subscribe({
next: (event: BookUpdateEvent) => this.addBookToCollection(event),
error: (error) => console.error('Error receiving progress updates:', error),
});
}
stopListeningForProgress(): void {
if (this.progressSubscription) {
this.progressSubscription.unsubscribe();
this.progressSubscription = undefined;
}
}
addBookToCollection(event: BookUpdateEvent): void {
if (event.parsingStatus === 'PARSED_NEW_BOOK') {
const newBook: Book = {
id: event.book.id,
libraryId: event.book.libraryId,
metadata: event.book.metadata,
};
this.ngZone.run(() => {
if (!this.books.find((book) => book.id === newBook.id)) {
this.books = [newBook, ...this.books];
}
});
} else {
console.warn('Status other than: PARSED_NEW_BOOK');
}
}
loadBooks(): void {
this.bookService.loadBooks(this.libraryIdSignal(), this.currentPage).subscribe({
next: (response) => {
this.books = [...this.books, ...response.content];
this.currentPage++;
},
error: (err) => console.error('Error loading books:', err),
});
}
coverImageSrc(bookId: number): string {
return this.bookService.getBookCoverUrl(bookId);
}
@@ -159,14 +100,4 @@ export class LibraryBrowserComponent implements OnInit, OnDestroy {
return this.coverSizeClass === this.coverSizeClasses[0];
}
resetState(): void {
this.books = [];
this.currentPage = 0;
this.stopListeningForProgress();
}
ngOnDestroy(): void {
this.stopListeningForProgress();
}
}

View File

@@ -4,16 +4,6 @@ export interface Book {
metadata: BookMetadata
}
export interface PaginatedBooksResponse {
content: Book[];
totalElements: number;
totalPages: number;
size: number;
number: number;
first: boolean;
last: boolean;
}
export interface BookMetadata {
thumbnail: string;
title: string;

View File

@@ -1,15 +1,13 @@
import {Observable, of} from 'rxjs';
import {Book, BookMetadata, BookSetting, BookWithNeighborsDTO, PaginatedBooksResponse} from '../model/book.model';
import {Book, BookMetadata, BookSetting, BookWithNeighborsDTO} from '../model/book.model';
import {computed, Injectable, signal} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {catchError, map} from 'rxjs/operators';
import {LibraryApiResponse} from '../model/library.model';
@Injectable({
providedIn: 'root',
})
export class BookService {
private readonly pageSize = 50;
private readonly libraryUrl = 'http://localhost:8080/v1/library';
private readonly bookUrl = 'http://localhost:8080/v1/book';
@@ -21,10 +19,40 @@ export class BookService {
latestAddedBooks = computed(this.#latestAddedBooks);
latestAddedBooksLoaded: boolean = false;
#libraryBooks = signal<Book[]>([]);
libraryBooks = computed(this.#libraryBooks);
lastLibraryBooksLoaded: boolean = false;
constructor(private http: HttpClient) {
}
appendBookToLibrary(book: Book) {
const newBook: Book = this.convertBookDTOToBook(book);
this.#libraryBooks.set([newBook, ...this.#libraryBooks()]);
}
private convertBookDTOToBook(book: Book): Book {
return {
id: book.id,
libraryId: book.libraryId,
metadata: {
thumbnail: book.metadata.thumbnail,
title: book.metadata.title,
subtitle: book.metadata.subtitle,
authors: book.metadata.authors,
categories: book.metadata.categories,
publisher: book.metadata.publisher,
publishedDate: book.metadata.publishedDate,
isbn10: book.metadata.isbn10,
description: book.metadata.description,
pageCount: book.metadata.pageCount,
language: book.metadata.language,
googleBookId: book.metadata.googleBookId,
}
};
}
getBook(bookId: number): Observable<Book> {
return this.http.get<Book>(`${this.bookUrl}/${bookId}`);
}
@@ -33,15 +61,31 @@ export class BookService {
return this.http.get<BookWithNeighborsDTO>(`${this.libraryUrl}/${libraryId}/book/${bookId}/withNeighbors`);
}
loadBooks(libraryId: number, page: number): Observable<PaginatedBooksResponse> {
return this.http.get<PaginatedBooksResponse>(
`${this.libraryUrl}/${libraryId}/book?page=${page}&size=${this.pageSize}`
loadBooksSignal(libraryId: number) {
this.http.get<Book[]>(`${this.libraryUrl}/${libraryId}/book`).pipe(
map(response => response),
catchError(error => {
console.error('Error loading library books:', error);
return of([]);
})
).subscribe(
(books) => {
this.#libraryBooks.set([...this.#libraryBooks(), ...books]);
this.lastReadBooksLoaded = true;
console.log("Loaded library books")
}
);
}
loadBooks(libraryId: number): Observable<Book[]> {
return this.http.get<Book[]>(
`${this.libraryUrl}/${libraryId}/book`
);
}
getLastReadBooks() {
this.http.get<PaginatedBooksResponse>(`${this.bookUrl}?page=0&size=25&sortBy=lastReadTime&sortDir=desc`).pipe(
map(response => response.content),
this.http.get<Book[]>(`${this.bookUrl}?sortBy=lastReadTime&sortDir=desc`).pipe(
map(response => response),
catchError(error => {
console.error('Error loading last read books:', error);
return of([]);
@@ -56,8 +100,8 @@ export class BookService {
}
getLatestAddedBooks() {
this.http.get<PaginatedBooksResponse>(`${this.bookUrl}?page=0&size=25&sortBy=addedOn&sortDir=desc`).pipe(
map(response => response.content),
this.http.get<Book[]>(`${this.bookUrl}?sortBy=addedOn&sortDir=desc`).pipe(
map(response => response),
catchError(error => {
console.error('Error loading latest added books:', error);
return of([]);

View File

@@ -0,0 +1,30 @@
import { RxStompConfig } from '@stomp/rx-stomp';
export const myRxStompConfig: RxStompConfig = {
// Which server?
brokerURL: 'ws://localhost:8080/ws',
// Headers
// Typical keys: login, passcode, host
connectHeaders: {
login: 'guest',
passcode: 'guest',
},
// How often to heartbeat?
// Interval in milliseconds, set to 0 to disable
heartbeatIncoming: 0, // Typical value 0 - disabled
heartbeatOutgoing: 20000, // Typical value 20000 - every 20 seconds
// Wait in milliseconds before attempting auto reconnect
// Set to 0 to disable
// Typical value 500 (500 milli seconds)
reconnectDelay: 200,
// Will log diagnostics on console
// It can be quite verbose, not recommended in production
// Skip this key to stop logging to console
debug: (msg: string): void => {
//console.log(new Date(), msg);
},
};

View File

@@ -0,0 +1,9 @@
import { RxStompService } from './rx-stomp.service';
import { myRxStompConfig } from './my-rx-stomp.config';
export function rxStompServiceFactory() {
const rxStomp = new RxStompService();
rxStomp.configure(myRxStompConfig);
rxStomp.activate();
return rxStomp;
}

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@angular/core';
import { RxStomp } from '@stomp/rx-stomp';
@Injectable({
providedIn: 'root',
})
export class RxStompService extends RxStomp {
constructor() {
super();
}
}