Added a minor loading animation when searching.

This commit is contained in:
Tom
2025-06-25 14:49:05 +00:00
parent 3326b7c589
commit 7875c5407c
3 changed files with 63 additions and 25 deletions

View File

@ -30,7 +30,8 @@
padding: 20px; padding: 20px;
} }
.results-error img, .results-end img { .results-error img,
.results-end img {
vertical-align: middle; vertical-align: middle;
} }
@ -40,4 +41,32 @@
.filter-warning { .filter-warning {
filter: brightness(0) saturate(100%) invert(52%) sepia(95%) saturate(2039%) hue-rotate(3deg) brightness(106%) contrast(102%); filter: brightness(0) saturate(100%) invert(52%) sepia(95%) saturate(2039%) hue-rotate(3deg) brightness(106%) contrast(102%);
}
.loading {
width: fit-content;
font-weight: bold;
font-family: monospace;
font-size: 25px;
align-self: center;
padding: 5px;
margin: 20px;
background: radial-gradient(circle closest-side, #000 94%, #0000) right/calc(200% - 1em) 100%;
animation: l24 1s infinite alternate linear;
}
.loading::before {
content: "Loading...";
line-height: 1em;
color: #0000;
background: inherit;
background-image: radial-gradient(circle closest-side, #fff 94%, #000);
-webkit-background-clip: text;
background-clip: text;
}
@keyframes l24 {
100% {
background-position: left
}
} }

View File

@ -1,21 +1,24 @@
<div class="search-content"> <div #scrollbar
<search-box class="search-content">
(searchOutput)="search.next($event)" <search-box (searchOutput)="search.next($event)"
(filtersOutput)="filters.next($event)" /> (filtersOutput)="filters.next($event)" />
<div class="results-box" <div class="results-box"
(scroll)="onResultsScroll($event)"> (scroll)="onResultsScroll($event)">
@for (result of results; track $index) { @for (result of results; track $index) {
<media-search-item class="result-item" <media-search-item class="result-item"
[media]="result" /> [media]="result" />
} }
@if (busy()) {
<div class="loading"></div>
}
@if (searchError() != null) { @if (searchError() != null) {
<p class="results-error"> <p class="results-error">
<img src="/icons/error_icon.svg" <img src="/icons/error_icon.svg"
alt="error icon" alt="error icon"
class="filter-error" /> class="filter-error" />
{{searchError()}} {{searchError()}}
</p> </p>
} }
@if (endOfResults()) { @if (endOfResults()) {
<div class="results-end"> <div class="results-end">
<img src="/icons/warning_icon.svg" <img src="/icons/warning_icon.svg"

View File

@ -1,9 +1,7 @@
import { Component, inject, NgZone, OnDestroy, signal } from '@angular/core'; import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
import { AuthService } from '../../services/auth/auth.service';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, scan, Subscription, tap, throttleTime } from 'rxjs'; import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, scan, Subscription, throttleTime } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { RedirectionService } from '../../services/redirection.service';
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto'; import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
import { MediaSearchItemComponent } from '../media-search-item/media-search-item.component'; import { MediaSearchItemComponent } from '../media-search-item/media-search-item.component';
import { SearchBoxComponent } from "../search-box/search-box.component"; import { SearchBoxComponent } from "../search-box/search-box.component";
@ -21,17 +19,18 @@ import { SearchContextDto } from '../../shared/dto/search-context.dto';
styleUrl: './add-new-page.component.css' styleUrl: './add-new-page.component.css'
}) })
export class AddNewPageComponent implements OnDestroy { export class AddNewPageComponent implements OnDestroy {
private readonly _auth = inject(AuthService);
private readonly _http = inject(HttpClient); private readonly _http = inject(HttpClient);
private readonly _redirect = inject(RedirectionService);
private readonly _subscriptions: Subscription[] = []; private readonly _subscriptions: Subscription[] = [];
private readonly _zone = inject(NgZone); private readonly _zone = inject(NgZone);
@ViewChild('scrollbar') private readonly searchContentRef: ElementRef<Element> = {} as ElementRef;
search = new BehaviorSubject<string>(''); search = new BehaviorSubject<string>('');
filters = new BehaviorSubject<SearchContextDto>(new SearchContextDto()); filters = new BehaviorSubject<SearchContextDto>(new SearchContextDto());
page = new BehaviorSubject<number>(0); page = new BehaviorSubject<number>(0);
results: BookSearchResultDto[] = []; results: BookSearchResultDto[] = [];
resultsPerPage = signal<number>(10); resultsPerPage = signal<number>(10);
busy = signal<boolean>(false);
endOfResults = signal<boolean>(false); endOfResults = signal<boolean>(false);
searchError = signal<string | null>(null); searchError = signal<string | null>(null);
@ -45,13 +44,14 @@ export class AddNewPageComponent implements OnDestroy {
), ),
filters: this.filters, filters: this.filters,
page: this.page.pipe( page: this.page.pipe(
throttleTime(1500, undefined, { leading: true, trailing: true }), throttleTime(1500, undefined, { leading: false, trailing: true }),
), ),
}).pipe( }).pipe(
filter(entry => entry.search!.length > 1), filter(entry => entry.search!.length > 1),
throttleTime(1000, undefined, { leading: false, trailing: true }), debounceTime(1000),
scan((acc, next) => { scan((acc, next) => {
// New searches means resetting to page 0. // New searches means resetting to page 0.
// Ignore if user is busy loading new pages.
if (acc.search != next.search) { if (acc.search != next.search) {
this.results = []; this.results = [];
return { return {
@ -65,8 +65,10 @@ export class AddNewPageComponent implements OnDestroy {
return acc; return acc;
} }
// Ignore further searching on the same search term. // Ignore further page searching if:
if (this.endOfResults()) { // - there are no more results;
// - user is still busy loading new pages.
if (this.endOfResults() || this.busy()) {
return { return {
...next, ...next,
page: -1, page: -1,
@ -79,10 +81,12 @@ export class AddNewPageComponent implements OnDestroy {
page: Math.min(acc.page + 1, next.page), page: Math.min(acc.page + 1, next.page),
}; };
}), }),
distinctUntilChanged(),
filter(entry => entry.page >= 0), filter(entry => entry.page >= 0),
tap(_ => this.endOfResults.set(false)), distinctUntilChanged(),
).subscribe((entry) => { ).subscribe((entry) => {
this.busy.set(true);
this.endOfResults.set(false);
this.searchContentRef.nativeElement.scrollTop = 0;
this._http.get('/api/providers/search', this._http.get('/api/providers/search',
{ {
params: { params: {
@ -99,8 +103,10 @@ export class AddNewPageComponent implements OnDestroy {
if (results.length < this.resultsPerPage()) { if (results.length < this.resultsPerPage()) {
this.endOfResults.set(true); this.endOfResults.set(true);
} }
this.busy.set(false);
}, },
error: (err) => { error: (err) => {
this.busy.set(false);
if (err instanceof HttpErrorResponse) { if (err instanceof HttpErrorResponse) {
if (err.status == 400) { if (err.status == 400) {
this.searchError.set('Something went wrong when Google received the request.'); this.searchError.set('Something went wrong when Google received the request.');
@ -128,7 +134,7 @@ export class AddNewPageComponent implements OnDestroy {
const limit = scroll.scrollHeight - scroll.clientHeight; const limit = scroll.scrollHeight - scroll.clientHeight;
// Prevent page changes when: // Prevent page changes when:
// - new search is happening (emptying results); // - new search is happening (emptied results);
// - still scrolling through current content. // - still scrolling through current content.
if (scroll.scrollTop == 0 || scroll.scrollTop < limit - 25) { if (scroll.scrollTop == 0 || scroll.scrollTop < limit - 25) {
return; return;