From 21ea665eb24c991a62a95c1d17a76bce128cff5b Mon Sep 17 00:00:00 2001 From: Maksym Diachuk Date: Sun, 7 Dec 2025 23:31:19 +0200 Subject: [PATCH] Implement fulltext search. Improve styling --- .../app/components/book-card/book-card.html | 2 +- .../components/book-details/book-details.css | 15 ++ .../components/book-details/book-details.html | 4 +- .../app/components/dialog/confirm-dialog.ts | 44 +++++ .../src/app/components/footer/footer.html | 89 +++++++--- .../src/app/components/header/header.css | 10 ++ .../src/app/components/header/header.html | 128 +++++++++----- book-bazaar/src/app/components/home/home.html | 167 +++++++++++++----- book-bazaar/src/app/components/home/home.ts | 63 ++++++- .../app/components/my-reviews/my-reviews.css | 13 ++ .../app/components/my-reviews/my-reviews.html | 4 +- .../components/review-form/review-form.html | 153 ++++++++-------- .../app/components/review-form/review-form.ts | 12 +- .../components/search-books/search-books.css | 15 ++ .../components/search-books/search-books.html | 2 +- .../components/search-books/search-books.ts | 16 +- .../user-review-card/user-review-card.html | 2 +- .../user-review-card/user-review-card.ts | 25 ++- .../src/app/services/book/book-service.ts | 2 +- book-bazaar/src/custom-theme.scss | 2 - .../config/MatchFunctionContributor.java | 17 ++ .../model/book/BookSpecificationBuilder.java | 4 +- .../model/book/FullTextSpecification.java | 41 +++++ .../book/FullTextSpecificationBuilder.java | 21 +++ ...g.hibernate.boot.model.FunctionContributor | 1 + .../V03__add_full-text_search_index.sql | 2 + 26 files changed, 631 insertions(+), 223 deletions(-) create mode 100644 book-bazaar/src/app/components/dialog/confirm-dialog.ts create mode 100644 bookService/src/main/java/org/library/bookservice/config/MatchFunctionContributor.java create mode 100644 bookService/src/main/java/org/library/bookservice/filtering/model/book/FullTextSpecification.java create mode 100644 bookService/src/main/java/org/library/bookservice/filtering/model/book/FullTextSpecificationBuilder.java create mode 100644 bookService/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor create mode 100644 bookService/src/main/resources/db/migration/V03__add_full-text_search_index.sql diff --git a/book-bazaar/src/app/components/book-card/book-card.html b/book-bazaar/src/app/components/book-card/book-card.html index 1bf349d..faff59b 100644 --- a/book-bazaar/src/app/components/book-card/book-card.html +++ b/book-bazaar/src/app/components/book-card/book-card.html @@ -1,7 +1,7 @@
+ class="h-full flex flex-col w-full border-gray-200 !bg-white transition-all duration-300 group-hover:shadow-lg group-hover:border-primary/20 group-hover:-translate-y-1">
diff --git a/book-bazaar/src/app/components/book-details/book-details.css b/book-bazaar/src/app/components/book-details/book-details.css index 11bcb42..f806e9c 100644 --- a/book-bazaar/src/app/components/book-details/book-details.css +++ b/book-bazaar/src/app/components/book-details/book-details.css @@ -42,4 +42,19 @@ height: auto; width: auto; font-size: 24px; +} + +::ng-deep .custom-user-menu { + border-radius: 16px !important; + overflow: hidden !important; + min-width: 220px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.01) !important; + margin-top: 8px !important; + + /* ДОДАНО: Примусовий білий фон */ + background-color: white !important; +} + +::ng-deep .mat-mdc-paginator { + background-color: white !important; } \ No newline at end of file diff --git a/book-bazaar/src/app/components/book-details/book-details.html b/book-bazaar/src/app/components/book-details/book-details.html index ca151f0..1b153d7 100644 --- a/book-bazaar/src/app/components/book-details/book-details.html +++ b/book-bazaar/src/app/components/book-details/book-details.html @@ -211,7 +211,7 @@

expand_more - +

-
+
+

+ Delete Review? +

+ +

+ This action cannot be undone. Are you sure you want to remove this review permanently? +

+ +
+ + + +
+
+ `, + styles: [` + :host { + display: block; + background: white; + } + `] +}) +export class ConfirmDialog { + readonly dialogRef = inject(MatDialogRef); +} \ No newline at end of file diff --git a/book-bazaar/src/app/components/footer/footer.html b/book-bazaar/src/app/components/footer/footer.html index 6b5c2c8..18792b0 100644 --- a/book-bazaar/src/app/components/footer/footer.html +++ b/book-bazaar/src/app/components/footer/footer.html @@ -1,30 +1,77 @@ -
-
-
-
- - BookBazaar +
+ \ No newline at end of file diff --git a/book-bazaar/src/app/components/header/header.css b/book-bazaar/src/app/components/header/header.css index e69de29..e3250a5 100644 --- a/book-bazaar/src/app/components/header/header.css +++ b/book-bazaar/src/app/components/header/header.css @@ -0,0 +1,10 @@ +::ng-deep .custom-user-menu { + border-radius: 16px !important; + overflow: hidden !important; + min-width: 220px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.01) !important; + margin-top: 8px !important; + + /* ДОДАНО: Примусовий білий фон */ + background-color: white !important; +} \ No newline at end of file diff --git a/book-bazaar/src/app/components/header/header.html b/book-bazaar/src/app/components/header/header.html index f4ff325..09a993a 100644 --- a/book-bazaar/src/app/components/header/header.html +++ b/book-bazaar/src/app/components/header/header.html @@ -1,52 +1,90 @@ -
-
- +
+
+
+
+ +
+ local_library +
+ + BookBazaar + +
-
- @if (userService.isLoggedIn()) { - + +
- - - - - - } @else { -
- - + +
+

Signed in as

+

{{ userService.userProfile()?.email }}

+
+ + + + +
+ +
+
+ + } @else { +
+ + +
+ }
- }
-
\ No newline at end of file + \ No newline at end of file diff --git a/book-bazaar/src/app/components/home/home.html b/book-bazaar/src/app/components/home/home.html index d724614..dd1c2ce 100644 --- a/book-bazaar/src/app/components/home/home.html +++ b/book-bazaar/src/app/components/home/home.html @@ -1,45 +1,101 @@
-
-
- -
-
-

- Discover Your - Next Great Read -

- -

- Explore thousands of books, read authentic reviews, and connect with fellow book lovers in our digital library. -

- -
-
-
- - -
+
+ +
+
+
+
+
+
+
+

+ Discover Your + Next Great Read +

+ +

+ Explore thousands of books, read authentic reviews, and connect with fellow book lovers in our digital + library. +

+ +
+
+
+
+ + +
+ + @if (showDropdown() && liveSearchResults().length > 0) { + + } +
-
-
- Books Illustration -
+
+ Books Illustration +
+
-
-
+
@@ -72,15 +128,34 @@

Browse by Gen

-
-
-

Popular this week

- -
-
- @for (book of popularBooks(); track $index) { - - } +
+
+ +
+
+

+ Popular This Week +

+

+ Trending books selected by our community readers +

+
+ + +
+ +
+ @for (book of popularBooks(); track $index) { + + } +
+
diff --git a/book-bazaar/src/app/components/home/home.ts b/book-bazaar/src/app/components/home/home.ts index cb4a9d7..af3b1de 100644 --- a/book-bazaar/src/app/components/home/home.ts +++ b/book-bazaar/src/app/components/home/home.ts @@ -1,8 +1,6 @@ import { Component, inject, signal } from '@angular/core'; -import { MatFormField } from '@angular/material/input'; -import { MatInput } from '@angular/material/input'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { Genre } from '../../model/genre'; import { MatRipple } from '@angular/material/core'; import { MatButton } from '@angular/material/button'; @@ -10,17 +8,24 @@ import { Book } from '../../model/book'; import { BookService } from '../../services/book/book-service'; import { BookCard } from "../book-card/book-card"; import { GenreService } from '../../services/genre/genre-service'; +import { debounceTime, distinctUntilChanged, switchMap, filter, tap, catchError } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { of } from 'rxjs'; +import { CurrencyPipe, DecimalPipe } from '@angular/common'; +import { MatIcon } from "@angular/material/icon"; @Component({ selector: 'app-home', imports: [ - MatFormField, - MatInput, ReactiveFormsModule, MatRipple, MatButton, - BookCard - ], + BookCard, + CurrencyPipe, + DecimalPipe, + RouterModule, + MatIcon +], templateUrl: './home.html', styleUrl: './home.css', }) @@ -35,9 +40,15 @@ export class Home { ); protected browsingGenres = signal([]); - protected popularBooks = signal([]); + protected liveSearchResults = signal([]); + protected showDropdown = signal(false); + + constructor() { + this.setupLiveSearch(); + } + ngOnInit() { this.bookService.getBooks({}, 0, 5) .subscribe(page => this.popularBooks.set(page.items)); @@ -45,14 +56,48 @@ export class Home { .subscribe(page => this.browsingGenres.set(page.items)); } + private setupLiveSearch() { + this.formGroup.get('search')?.valueChanges.pipe( + debounceTime(300), // Чекаємо 300мс + distinctUntilChanged(), + tap(val => { + // Якщо поле очистили - ховаємо дропдаун + if (!val) { + this.showDropdown.set(false); + this.liveSearchResults.set([]); + } + }), + // Фільтруємо пусті запити, щоб не слати зайве на сервер + filter(val => !!val && val.length > 1), + switchMap(query => { + // Використовуємо наш сервіс з параметром q (який ми налаштували раніше) + // Запитуємо лише 5 книг для прев'ю + return this.bookService.getBooks({ query: query }, 0, 5).pipe( + // Якщо сталась помилка, повертаємо пустий масив, щоб не ламати потік + catchError(() => of({ items: [], total: 0 })) + ); + }), + takeUntilDestroyed() + ).subscribe(response => { + // @ts-ignore (якщо у вас strict mode і catchError повертає не зовсім PaginatedResult) + const books = response.items || []; + this.liveSearchResults.set(books); + this.showDropdown.set(books.length > 0); + }); + } + protected search() { let filter = this.formGroup.value.search; if (!filter) return; - + this.showDropdown.set(false); this.router.navigate(['/search'], { queryParams: { query: filter } }); } protected searchByGenre(genre: Genre) { this.router.navigate(['/search'], { queryParams: { genre: genre.name } }); } + + closeDropdown() { + setTimeout(() => this.showDropdown.set(false), 200); + } } diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.css b/book-bazaar/src/app/components/my-reviews/my-reviews.css index e69de29..4b96b87 100644 --- a/book-bazaar/src/app/components/my-reviews/my-reviews.css +++ b/book-bazaar/src/app/components/my-reviews/my-reviews.css @@ -0,0 +1,13 @@ +::ng-deep .mat-mdc-paginator { + background-color: white !important; +} + +::ng-deep .custom-user-menu { + border-radius: 16px !important; + overflow: hidden !important; + min-width: 220px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.01) !important; + margin-top: 8px !important; + + background-color: white !important; +} \ No newline at end of file diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.html b/book-bazaar/src/app/components/my-reviews/my-reviews.html index d1a2dfb..02ffdd8 100644 --- a/book-bazaar/src/app/components/my-reviews/my-reviews.html +++ b/book-bazaar/src/app/components/my-reviews/my-reviews.html @@ -18,7 +18,7 @@

My Reviews

expand_more - +
-
+
diff --git a/book-bazaar/src/app/components/review-form/review-form.html b/book-bazaar/src/app/components/review-form/review-form.html index 67d1962..d031cbf 100644 --- a/book-bazaar/src/app/components/review-form/review-form.html +++ b/book-bazaar/src/app/components/review-form/review-form.html @@ -1,93 +1,92 @@
- + @if (book(); as bookData) { -
- -
-
-
- -
-

- {{ bookData.title }} -

-

- by {{ bookData.author?.name }} -

+
+ +
+
+
+
+

+ {{ bookData.title }} +

+

+ by {{ bookData.author?.name }} +

+
-
-
-

- {{ existingReview() ? 'Edit your review' : 'Write a review' }} -

-

- Share your thoughts with other readers. What did you like or dislike? -

-
- -
- -
-
- - - {{ ratingLabel }} - -
- -
- @for (star of [1,2,3,4,5]; track star) { - - {{ star <= (form.value.rating || 0) ? 'star' : 'star_border' }} - - } -
- @if (form.controls.rating.invalid && form.controls.rating.touched) { -

- error - Please select a rating star to proceed. -

- } +
+
+

+ {{ existingReview() ? 'Edit your review' : 'Write a review' }} +

+

+ Share your thoughts with other readers. What did you like or dislike? +

+
+ + + +
+
+ + + {{ ratingLabel }} +
- -
- - - - - {{form.value.text?.length || 0}}/1000 characters - - @if (form.controls.text.hasError('maxlength')) { - Review cannot exceed 1000 characters + +
+ @for (star of [1,2,3,4,5]; track star) { + + {{ star <= (form.value.rating || 0) ? 'star' : 'star_border' }} } - -
- -
- -
- -
+ @if (form.controls.rating.invalid && form.controls.rating.touched) { +

+ error + Please select a rating star to proceed. +

+ } +
+
+ + + + + {{form.value.text?.length || 0}}/1000 characters + + @if (form.controls.text.hasError('maxlength')) { + Review cannot exceed 1000 characters + } + +
+ +
+ + +
+
+ +
}
\ No newline at end of file diff --git a/book-bazaar/src/app/components/review-form/review-form.ts b/book-bazaar/src/app/components/review-form/review-form.ts index e86dba8..a9199fa 100644 --- a/book-bazaar/src/app/components/review-form/review-form.ts +++ b/book-bazaar/src/app/components/review-form/review-form.ts @@ -5,19 +5,17 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -// 👇 Додаємо Tooltip import { MatTooltipModule } from '@angular/material/tooltip'; import { BookService } from '../../services/book/book-service'; import { UserService } from '../../services/user/userService'; import { Review } from '../../model/review'; import { Book } from '../../model/book'; -import { NgClass } from '@angular/common'; +import { NgClass, Location } from '@angular/common'; import { ReviewService } from '../../services/review/review-service'; @Component({ selector: 'app-review-form', standalone: true, - // 👇 Додаємо MatTooltipModule та NgClass imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatTooltipModule, NgClass], templateUrl: './review-form.html', }) @@ -28,7 +26,8 @@ export class ReviewForm implements OnInit { private reviewService = inject(ReviewService); private bookService = inject(BookService); private userService = inject(UserService); - + private location = inject(Location); + bookId = signal(0); book = signal(null); existingReview = signal(null); @@ -38,7 +37,6 @@ export class ReviewForm implements OnInit { text: ['', [Validators.maxLength(1000)]] }); - // 👇 Допоміжний гетер для тексту оцінки get ratingLabel(): string { const rating = this.form.value.rating || 0; switch (rating) { @@ -101,13 +99,13 @@ export class ReviewForm implements OnInit { : this.reviewService.createReview(request); obs$.subscribe({ - next: () => this.router.navigate(['/book-details', this.bookId()]), + next: () => this.location.back(), error: (err) => console.error('Failed to save review', err) }); } cancel() { - this.router.navigate(['/book-details', this.bookId()]); + this.location.back(); } get hasUnsavedChanges(): boolean { diff --git a/book-bazaar/src/app/components/search-books/search-books.css b/book-bazaar/src/app/components/search-books/search-books.css index 9b5f4d6..87c01e8 100644 --- a/book-bazaar/src/app/components/search-books/search-books.css +++ b/book-bazaar/src/app/components/search-books/search-books.css @@ -53,4 +53,19 @@ mat-slider { width: 100%; +} + +::ng-deep .mat-mdc-paginator { + background-color: white !important; +} + +::ng-deep .custom-user-menu { + border-radius: 16px !important; + overflow: hidden !important; + min-width: 220px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.01) !important; + margin-top: 8px !important; + + /* ДОДАНО: Примусовий білий фон */ + background-color: white !important; } \ No newline at end of file diff --git a/book-bazaar/src/app/components/search-books/search-books.html b/book-bazaar/src/app/components/search-books/search-books.html index 5b0044d..63b4492 100644 --- a/book-bazaar/src/app/components/search-books/search-books.html +++ b/book-bazaar/src/app/components/search-books/search-books.html @@ -128,7 +128,7 @@

expand_more - + diff --git a/book-bazaar/src/app/components/search-books/search-books.ts b/book-bazaar/src/app/components/search-books/search-books.ts index 6fe0f5e..80e2224 100644 --- a/book-bazaar/src/app/components/search-books/search-books.ts +++ b/book-bazaar/src/app/components/search-books/search-books.ts @@ -23,6 +23,8 @@ import { MatProgressSpinner } from "@angular/material/progress-spinner"; import { MatSliderModule } from '@angular/material/slider'; import { MatMenuModule } from '@angular/material/menu'; import { FormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-search-books', @@ -89,6 +91,16 @@ export class SearchBooks { return hasPrice || hasRating || hasDynamicFilters; }); + constructor() { + this.formGroup.get('search')?.valueChanges.pipe( + debounceTime(500), // Чекаємо 500мс після останнього натискання клавіші + distinctUntilChanged(), // Не відправляємо запит, якщо текст не змінився + takeUntilDestroyed() // Автоматично відписуємось при знищенні компонента + ).subscribe(value => { + this.search(); + }); + } + ngOnInit(): void { const filters$ = this.loadFilters(); @@ -211,8 +223,8 @@ export class SearchBooks { const qp: Record = {}; Object.entries(output).forEach(([k, v]) => { - if (v && v.length) qp[k] = v; // arrays become repeated params - else qp[k] = null; // remove empty params + if (v && v.length) qp[k] = v; + else qp[k] = null; }); qp['page'] = 0; diff --git a/book-bazaar/src/app/components/user-review-card/user-review-card.html b/book-bazaar/src/app/components/user-review-card/user-review-card.html index 62bf4e2..23a17a6 100644 --- a/book-bazaar/src/app/components/user-review-card/user-review-card.html +++ b/book-bazaar/src/app/components/user-review-card/user-review-card.html @@ -30,7 +30,7 @@

- +