Added late read module

This commit is contained in:
aditya.chandel
2024-12-13 15:42:14 -07:00
parent baff376b9c
commit 1e9249b257
20 changed files with 346 additions and 69 deletions

View File

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Random;
@RequestMapping("/v1/book")
@RestController
@@ -30,9 +31,13 @@ public class BookController {
return ResponseEntity.ok(booksService.getBook(bookId));
}
@GetMapping()
public ResponseEntity<Page<BookDTO>> getBooks(@RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "25") @Min(1) @Max(100) int size) {
Page<BookDTO> books = booksService.getBooks(page, size);
@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) {
Page<BookDTO> books = booksService.getBooks(page, size, sortBy, sortDir);
return ResponseEntity.ok(books);
}
@@ -44,6 +49,14 @@ public class BookController {
@GetMapping("/{bookId}/cover")
public ResponseEntity<Resource> getBookCover(@PathVariable long bookId) {
Random random = new Random();
int delay = 250 + random.nextInt(750);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok(booksService.getBookCover(bookId));
}
@@ -52,9 +65,20 @@ public class BookController {
return booksService.getBookData(bookId);
}
@GetMapping("/{bookId}/viewer-setting")
public ResponseEntity<BookViewerSettingDTO> getBookViewerSettings(@PathVariable long bookId) {
return ResponseEntity.ok(booksService.getBookViewerSetting(bookId));
}
@PutMapping("/{bookId}/viewer-setting")
public ResponseEntity<Void> updateBookViewerSettings(@RequestBody BookViewerSettingDTO bookViewerSettingDTO, @PathVariable long bookId) {
booksService.saveBookViewerSetting(bookId, bookViewerSettingDTO);
return ResponseEntity.noContent().build();
}
@PutMapping("/{bookId}/update-last-read")
public ResponseEntity<Void> updateBookViewerSettings(@PathVariable long bookId) {
booksService.updateLastReadTime(bookId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -3,6 +3,7 @@ package com.adityachandel.booklore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -13,5 +14,6 @@ public class BookDTO {
private Long libraryId;
private String fileName;
private String title;
private Instant lastReadTime;
private List<AuthorDTO> authors = new ArrayList<>();
}

View File

@@ -3,6 +3,7 @@ package com.adityachandel.booklore.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.List;
@Entity
@@ -40,4 +41,7 @@ public class Book {
@OneToOne(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true)
private BookViewerSetting viewerSetting;
@Column(name = "last_read_time")
private Instant lastReadTime;
}

View File

@@ -19,5 +19,7 @@ public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificat
Optional<Book> findBookByIdAndLibraryId(long id, long libraryId);
List<Book> findByTitleContainingIgnoreCase(String title);
Page<Book> findByLastReadTimeIsNotNull(Pageable pageable);
}

View File

@@ -10,6 +10,7 @@ import com.adityachandel.booklore.exception.ErrorCode;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.BookViewerSettingRepository;
import com.adityachandel.booklore.service.parser.PdfParser;
import com.adityachandel.booklore.transformer.BookSettingTransformer;
import com.adityachandel.booklore.transformer.BookTransformer;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,6 +19,7 @@ import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@@ -28,6 +30,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -49,10 +52,13 @@ public class BooksService {
return BookTransformer.convertToBookDTO(book);
}
public Page<BookDTO> getBooks(int page, int size) {
PageRequest pageRequest = PageRequest.of(page, size);
Page<Book> bookPage = bookRepository.findAll(PageRequest.of(page, size));
List<BookDTO> bookDTOs = bookPage.getContent().stream().map(BookTransformer::convertToBookDTO).collect(Collectors.toList());
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 = bookRepository.findByLastReadTimeIsNotNull(pageRequest);
List<BookDTO> bookDTOs = bookPage.getContent().stream()
.map(BookTransformer::convertToBookDTO)
.collect(Collectors.toList());
return new PageImpl<>(bookDTOs, pageRequest, bookPage.getTotalElements());
}
@@ -102,10 +108,6 @@ public class BooksService {
Book book = pdfParser.parseBook(filePath.toAbsolutePath().toString(), appProperties.getPathConfig());
book.setViewerSetting(BookViewerSetting.builder()
.bookId(book.getId())
.pageNumber(0)
.zoom("page-fit")
.spread("off")
.sidebar_visible(false)
.build());
return book;
}
@@ -147,4 +149,15 @@ public class BooksService {
List<Book> books = bookRepository.findByTitleContainingIgnoreCase(title);
return books.stream().map(BookTransformer::convertToBookDTO).toList();
}
public BookViewerSettingDTO getBookViewerSetting(long bookId) {
BookViewerSetting bookViewerSetting = bookViewerSettingRepository.findById(bookId).orElseThrow(() -> ErrorCode.BOOK_NOT_FOUND.createException(bookId));
return BookSettingTransformer.convertToDTO(bookViewerSetting);
}
public void updateLastReadTime(long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ErrorCode.BOOK_NOT_FOUND.createException(bookId));
book.setLastReadTime(Instant.now());
bookRepository.save(book);
}
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.transformer;
import com.adityachandel.booklore.dto.BookViewerSettingDTO;
import com.adityachandel.booklore.entity.BookViewerSetting;
public class BookSettingTransformer {
public static BookViewerSettingDTO convertToDTO(BookViewerSetting bookViewerSetting) {
return BookViewerSettingDTO.builder()
.zoom(bookViewerSetting.getZoom())
.pageNumber(bookViewerSetting.getPageNumber())
.spread(bookViewerSetting.getSpread())
.sidebar_visible(bookViewerSetting.isSidebar_visible())
.build();
}
}

View File

@@ -13,6 +13,7 @@ public class BookTransformer {
bookDTO.setLibraryId(book.getLibrary().getId());
bookDTO.setFileName(book.getFileName());
bookDTO.setTitle(book.getTitle());
bookDTO.setLastReadTime(book.getLastReadTime());
bookDTO.setAuthors(book.getAuthors().stream().map(AuthorTransformer::toAuthorDTO).collect(Collectors.toList()));
return bookDTO;
}

View File

@@ -7,13 +7,16 @@ CREATE TABLE IF NOT EXISTS library
CREATE TABLE IF NOT EXISTS book
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255),
file_name VARCHAR(255) NOT NULL,
library_id BIGINT NOT NULL,
path VARCHAR(1000) NOT NULL,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255),
file_name VARCHAR(255) NOT NULL,
library_id BIGINT NOT NULL,
path VARCHAR(1000) NOT NULL,
last_read_time TIMESTAMP NULL,
CONSTRAINT fk_library FOREIGN KEY (library_id) REFERENCES library (id) ON DELETE CASCADE,
CONSTRAINT unique_file_library UNIQUE (file_name, library_id)
CONSTRAINT unique_file_library UNIQUE (file_name, library_id),
INDEX idx_library_id (library_id),
INDEX idx_last_read_time (last_read_time)
);
CREATE TABLE IF NOT EXISTS author
@@ -29,15 +32,17 @@ CREATE TABLE IF NOT EXISTS book_author_mapping
author_id BIGINT NOT NULL,
CONSTRAINT fk_book_author_mapping_book FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE,
CONSTRAINT fk_book_author_mapping_author FOREIGN KEY (author_id) REFERENCES author (id),
CONSTRAINT unique_book_author UNIQUE (book_id, author_id)
CONSTRAINT unique_book_author UNIQUE (book_id, author_id),
INDEX idx_book_id (book_id),
INDEX idx_author_id (author_id)
);
CREATE TABLE IF NOT EXISTS book_viewer_setting
(
book_id BIGINT PRIMARY KEY,
page_number INT DEFAULT 1,
zoom VARCHAR(32) DEFAULT 'page-fit',
zoom VARCHAR(16) DEFAULT 'page-fit',
sidebar_visible BOOLEAN DEFAULT false,
spread VARCHAR(32) DEFAULT 'off',
spread VARCHAR(16) DEFAULT 'odd',
CONSTRAINT fk_book_viewer_setting FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE
);

View File

@@ -17,9 +17,9 @@ import {InputIconModule} from 'primeng/inputicon';
import {ToggleButtonModule} from 'primeng/togglebutton';
import {PasswordModule} from 'primeng/password';
import {ToastModule} from 'primeng/toast';
import { LibraryBrowserComponent } from './book/component/library-browser/library-browser.component';
import {LibraryBrowserComponent} from './book/component/library-browser/library-browser.component';
import {InfiniteScrollDirective} from 'ngx-infinite-scroll';
import { SearchComponent } from './book/component/search/search.component';
import {SearchComponent} from './book/component/search/search.component';
@NgModule({
declarations: [

View File

@@ -0,0 +1,20 @@
<div class="book-list-container">
<h2 class="last-read-title">Last Read</h2>
<div class="book-list"
infiniteScroll
[infiniteScrollDistance]="2"
[infiniteScrollThrottle]="50"
(scrolled)="loadMore()"
#scrollContainer
(scroll)="onScroll($event)">
<div class="book-item" *ngFor="let book of books">
<img [src]="coverImageSrc(book.id)" class="book-cover" alt="Cover of {{ book.title }}" loading="lazy"/>
<div class="book-info">
<h4>{{ book.title }}</h4>
<p>{{ getAuthorNames(book) }}</p>
<p-button label="View" icon="pi pi-eye" class="view-btn" (click)="openBook(book.id)"></p-button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,83 @@
.book-list-container {
margin-bottom: 20px;
}
.last-read-title {
font-size: 1.5rem;
color: var(--text-color);
margin-bottom: 10px;
padding: 5px;
text-align: left;
}
.book-list {
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
padding: 10px;
gap: 20px;
height: 100%;
box-sizing: border-box;
}
.book-item {
overflow: hidden;
background-color: var(--surface-card);
border-radius: 8px;
flex: 0 0 12%;
scroll-snap-align: start;
position: relative;
}
.book-cover {
height: 225px;
object-fit: cover;
background-color: #444;
transition: background-color 0.3s ease, filter 0.3s ease;
position: relative;
border: none;
}
.book-item:hover .book-cover {
filter: blur(2px);
}
.book-info {
padding: 5px;
text-align: center;
}
h4 {
font-size: 0.9rem;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
margin-bottom: 3px;
}
p {
color: var(--text-color-secondary);
font-size: 0.7rem;
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.view-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: none;
z-index: 1;
}
.book-item:hover .view-btn {
display: block;
}

View File

@@ -0,0 +1,68 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { Book } from '../../model/book.model';
import { BookService } from '../../service/book.service';
import { Button } from 'primeng/button';
import { InfiniteScrollDirective } from 'ngx-infinite-scroll';
import { NgForOf } from '@angular/common';
@Component({
selector: 'app-dashboard-scroller',
templateUrl: './dashboard-scroller.component.html',
imports: [
Button,
InfiniteScrollDirective,
NgForOf
],
styleUrls: ['./dashboard-scroller.component.scss']
})
export class DashboardScrollerComponent implements OnInit {
books: Book[] = [];
private currentPage: number = 0;
@ViewChild('scrollContainer') scrollContainer!: ElementRef;
constructor(private bookService: BookService) {}
ngOnInit(): void {
this.loadBooks();
}
loadBooks(): void {
this.bookService.loadLatestBooks(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);
}
loadMore(): void {
console.log('Loading more books...');
this.loadBooks();
}
getAuthorNames(book: Book): string {
return book.authors?.map((author) => author.name).join(', ') || 'No authors available';
}
openBook(bookId: number): void {
const url = `/pdf-viewer/book/${bookId}`;
window.open(url, '_blank');
}
onScroll(event: any): void {
const container = this.scrollContainer.nativeElement;
const scrollRight = container.scrollWidth - container.scrollLeft === container.clientWidth;
if (scrollRight) {
this.loadMore();
}
}
}

View File

@@ -1,11 +1,18 @@
<div class="dashboard">
<h1 *ngIf="isLibrariesEmpty" class="no-library-header">
Looks like you haven't added a library yet. Let's add one!
</h1>
<p-button *ngIf="isLibrariesEmpty"
label="Add a Library"
icon="pi pi-plus"
styleClass="p-button-rounded p-button-outlined"
(click)="createNewLibrary($event)">
</p-button>
<div *ngIf="isLibrariesEmpty; else dashboardScroller">
<h1 class="no-library-header">
Looks like you haven't added a library yet. Let's add one!
</h1>
<p-button
label="Add a Library"
icon="pi pi-plus"
styleClass="p-button-rounded p-button-outlined"
(click)="createNewLibrary($event)">
</p-button>
</div>
<ng-template #dashboardScroller>
<app-dashboard-scroller></app-dashboard-scroller>
</ng-template>
</div>

View File

@@ -1,27 +1,25 @@
.dashboard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 5rem;
text-align: center;
}
.no-library-header {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-color)
}
.p-dialog {
max-width: 90%; /* Ensure the dialog doesn't overflow the viewport */
}
.dashboard .no-library-header {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-color);
}
.dashboard .p-dialog {
max-width: 90%;
}
::ng-deep .p-dialog-mask {
background-color: rgba(0, 0, 0, 0.5); /* Darken the background with 50% opacity */
background-color: rgba(0, 0, 0, 0.5);
}
/* You can also adjust the blur if you want */
::ng-deep .p-dialog-mask {
backdrop-filter: blur(3px); /* Optional: Apply blur to the background */
backdrop-filter: blur(3px);
}

View File

@@ -5,16 +5,18 @@ import { Button } from 'primeng/button';
import { NgIf } from '@angular/common';
import { LibraryCreatorComponent } from '../library-creator/library-creator.component';
import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
import {DashboardScrollerComponent} from '../dashboard-scroller/dashboard-scroller.component';
@Component({
selector: 'app-home-page',
templateUrl: './dashboard.component.html',
imports: [
Button,
NgIf
NgIf,
DashboardScrollerComponent
],
styleUrls: ['./dashboard.component.scss'],
providers: [DialogService], // Ensure the DialogService is available
providers: [DialogService],
})
export class DashboardComponent {
private libraries: WritableSignal<Library[]>;
@@ -25,7 +27,7 @@ export class DashboardComponent {
}
get isLibrariesEmpty(): boolean {
return this.libraries()?.length === 1;
return this.libraries()?.length === 0;
}
createNewLibrary(event: MouseEvent) {
@@ -39,12 +41,12 @@ export class DashboardComponent {
this.ref = this.dialogService.open(LibraryCreatorComponent, {
modal: true,
width: `${dialogWidthPercentage}%`, // Dynamic width
height: 'auto', // Let height adapt to content
width: `${dialogWidthPercentage}%`,
height: 'auto',
style: {
position: 'absolute',
top: `${buttonRect.bottom + 10}px`, // Position below the button
left: `${Math.max(leftPosition, 0)}px`, // Ensure it stays within the viewport
top: `${buttonRect.bottom + 10}px`,
left: `${Math.max(leftPosition, 0)}px`
},
});
}

View File

@@ -1,4 +1,9 @@
<div class="book-list" infiniteScroll [infiniteScrollDistance]="2" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<div class="book-list"
infiniteScroll
[infiniteScrollDistance]="2"
[infiniteScrollThrottle]="50"
[horizontal]=true
(scrolled)="loadMore()">
<div class="book-item" *ngFor="let book of books">
<img [src]="coverImageSrc(book.id)" class="book-cover placeholder" alt="Cover of {{ book.title }}" loading="lazy"/>
<div class="book-info">

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, NgZone, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, NgZone, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NgxExtendedPdfViewerModule, ScrollModeType } from 'ngx-extended-pdf-viewer';
import { BookService } from '../../service/book.service';
@@ -9,7 +9,7 @@ import { BookService } from '../../service/book.service';
imports: [NgxExtendedPdfViewerModule],
templateUrl: './pdf-viewer.component.html',
})
export class PdfViewerComponent implements OnInit {
export class PdfViewerComponent implements OnInit, OnDestroy {
bookId!: number;
handTool = true;
page = 1;
@@ -31,18 +31,25 @@ export class PdfViewerComponent implements OnInit {
this.route.paramMap.subscribe((params) => {
this.bookId = +params.get('bookId')!;
this.loadBook(this.bookId);
this.updateLastReadTime();
});
}
ngOnDestroy(): void {
this.updateLastReadTime();
}
private loadBook(bookId: number): void {
this.bookService.getBook(bookId).subscribe((book) => {
this.zone.run(() => {
const { pageNumber, zoom, sidebar_visible, spread } = book.viewerSetting;
this.page = pageNumber || 1;
this.zoom = zoom || 'page-fit';
this.sidebarVisible = sidebar_visible || false;
this.spread = spread || 'odd';
this.isInitialLoad = false;
this.bookService.getBookSetting(bookId).subscribe((bookSetting) => {
const { pageNumber, zoom, sidebar_visible, spread } = bookSetting;
this.page = pageNumber || 1;
this.zoom = zoom || 'page-fit';
this.sidebarVisible = sidebar_visible || false;
this.spread = spread || 'odd';
this.isInitialLoad = false;
});
});
});
}
@@ -58,7 +65,12 @@ export class PdfViewerComponent implements OnInit {
this.bookService.updateViewerSetting(updatedViewerSetting, this.bookId).subscribe();
}
private updateLastReadTime(): void {
this.bookService.updateLastReadTime(this.bookId).subscribe();
}
onPageChange(page: number): void {
console.log('page', page);
if (page !== this.page) {
this.page = page;
this.updateViewerSetting();
@@ -66,6 +78,7 @@ export class PdfViewerComponent implements OnInit {
}
onZoomChange(zoom: string | number): void {
console.log('zoom', zoom);
if (zoom !== this.zoom) {
this.zoom = zoom;
this.updateViewerSetting();
@@ -73,6 +86,7 @@ export class PdfViewerComponent implements OnInit {
}
onSidebarVisibleChange(visible: boolean): void {
console.log('sidebarVisible', visible);
if (visible !== this.sidebarVisible) {
this.sidebarVisible = visible;
this.updateViewerSetting();

View File

@@ -0,0 +1,6 @@
export interface BookSetting {
pageNumber: number;
zoom: number | string;
sidebar_visible: boolean;
spread: 'off' | 'even' | 'odd';
}

View File

@@ -5,14 +5,6 @@ export interface Book {
libraryId: number;
title: string;
authors: Author[];
viewerSetting: BookViewerSetting;
}
export interface BookViewerSetting {
pageNumber: number;
zoom: number | string;
sidebar_visible: boolean;
spread: 'off' | 'even' | 'odd';
}
export interface PaginatedBooksResponse {

View File

@@ -2,6 +2,7 @@ import { Observable } from 'rxjs';
import { Book, PaginatedBooksResponse } from '../model/book.model';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {BookSetting} from '../model/book-settings.model';
@Injectable({
providedIn: 'root',
@@ -23,6 +24,12 @@ export class BookService {
);
}
loadLatestBooks(page: number): Observable<PaginatedBooksResponse> {
return this.http.get<PaginatedBooksResponse>(
`${this.bookUrl}?page=${page}&size=10&sortBy=lastReadTime&sortDir=desc`
);
}
searchBooks(query: string): Observable<Book[]> {
if (query.length < 3) {
return new Observable<Book[]>();
@@ -35,6 +42,10 @@ export class BookService {
return this.http.put<void>(url, viewerSetting);
}
getBookSetting(bookId: number): Observable<BookSetting> {
return this.http.get<BookSetting>(`${this.bookUrl}/${bookId}/viewer-setting`);
}
getBookDataUrl(bookId: number): string {
return `${this.bookUrl}/${bookId}/data`;
}
@@ -42,4 +53,8 @@ export class BookService {
getBookCoverUrl(bookId: number): string {
return `${this.bookUrl}/${bookId}/cover`;
}
updateLastReadTime(bookId: number): Observable<void> {
return this.http.put<void>(`${this.bookUrl}/${bookId}/update-last-read`, {});
}
}