Added media item modal when user clicks on an item to save/subscribe a new media.
This commit is contained in:
@@ -1,12 +1,21 @@
|
|||||||
import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, scan, Subscription, throttleTime } from 'rxjs';
|
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, map, scan, Subscription, tap, throttleTime } from 'rxjs';
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
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";
|
||||||
import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESULTS = 10;
|
||||||
|
|
||||||
|
const PROVIDER_SETTINGS = {
|
||||||
|
google: {
|
||||||
|
FilterSearchParams: ['inauthor', 'inpublisher', 'intitle', 'isbn', 'subject'],
|
||||||
|
FilterNoUpdateParams: ['maxResults'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'add-new-page',
|
selector: 'add-new-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -35,24 +44,36 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
searchError = signal<string | null>(null);
|
searchError = signal<string | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._zone.runOutsideAngular(() =>
|
this._zone.runOutsideAngular(() => {
|
||||||
|
// Subscription for max results.
|
||||||
|
this._subscriptions.push(
|
||||||
|
this.filters.pipe(
|
||||||
|
map(filters => 'maxResults' in filters.values ? parseInt(filters.values['maxResults']) : DEFAULT_MAX_RESULTS)
|
||||||
|
).subscribe(maxResults => {
|
||||||
|
this.resultsPerPage.set(maxResults);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Subscription for the search bar.
|
// Subscription for the search bar.
|
||||||
this._subscriptions.push(
|
this._subscriptions.push(
|
||||||
combineLatest({
|
combineLatest({
|
||||||
search: this.search.pipe(
|
search: this.search.pipe(
|
||||||
filter(value => value != null),
|
filter(value => value != null),
|
||||||
),
|
),
|
||||||
filters: this.filters,
|
filters: this.filters.pipe(
|
||||||
|
map(filters => ({ values: { ...filters.values } }))
|
||||||
|
),
|
||||||
page: this.page.pipe(
|
page: this.page.pipe(
|
||||||
throttleTime(1500, undefined, { leading: false, trailing: true }),
|
throttleTime(3000, undefined, { leading: false, trailing: true }),
|
||||||
),
|
),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
filter(entry => entry.search!.length > 1),
|
|
||||||
debounceTime(1000),
|
debounceTime(1000),
|
||||||
|
filter(entry => entry.search.length > 1 || this.isUsingSearchParamsInFilters(entry.filters)),
|
||||||
scan((acc, next) => {
|
scan((acc, next) => {
|
||||||
// New searches means resetting to page 0.
|
// Different search or filters means resetting to page 0.
|
||||||
// Ignore if user is busy loading new pages.
|
const searchChanged = acc.search != next.search && next.search && next.search.length > 1;
|
||||||
if (acc.search != next.search) {
|
const filtersChanged = this.hasFiltersMismatched(acc.filters, next.filters);
|
||||||
|
if (searchChanged || filtersChanged) {
|
||||||
this.results = [];
|
this.results = [];
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
@@ -60,21 +81,22 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep searching the same page until error stops.
|
|
||||||
if (this.searchError() != null) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore further page searching if:
|
// Ignore further page searching if:
|
||||||
// - there are no more results;
|
// - there are no more results;
|
||||||
// - user is still busy loading new pages.
|
// - user is still busy loading new pages;
|
||||||
if (this.endOfResults() || this.busy()) {
|
// - only max results filter changed.
|
||||||
|
if (this.endOfResults() || this.busy() || acc.filters.values['maxResults'] != next.filters.values['maxResults']) {
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
page: -1,
|
page: -1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep searching the same page until error stops.
|
||||||
|
if (this.searchError() != null) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
// Next page.
|
// Next page.
|
||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
@@ -86,15 +108,18 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
).subscribe((entry) => {
|
).subscribe((entry) => {
|
||||||
this.busy.set(true);
|
this.busy.set(true);
|
||||||
this.endOfResults.set(false);
|
this.endOfResults.set(false);
|
||||||
this.searchContentRef.nativeElement.scrollTop = 0;
|
if (this.searchContentRef) {
|
||||||
|
this.searchContentRef.nativeElement.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
this._http.get('/api/providers/search',
|
this._http.get('/api/providers/search',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...this.filters.value.values,
|
...entry.filters.values,
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
search: entry.search!,
|
search: entry.search!,
|
||||||
startIndex: entry.page * this.resultsPerPage(),
|
startIndex: entry.page * this.resultsPerPage(),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (results: any) => {
|
next: (results: any) => {
|
||||||
@@ -103,6 +128,7 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
if (results.length < this.resultsPerPage()) {
|
if (results.length < this.resultsPerPage()) {
|
||||||
this.endOfResults.set(true);
|
this.endOfResults.set(true);
|
||||||
}
|
}
|
||||||
|
this.searchError.set(null);
|
||||||
this.busy.set(false);
|
this.busy.set(false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -121,8 +147,8 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -142,4 +168,34 @@ export class AddNewPageComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.page.next(this.page.getValue() + 1);
|
this.page.next(this.page.getValue() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasFiltersMismatched(prev: SearchContextDto, next: SearchContextDto) {
|
||||||
|
if (prev == next) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key in prev.values) {
|
||||||
|
if (PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key))
|
||||||
|
continue;
|
||||||
|
if (!(key in next.values))
|
||||||
|
return true;
|
||||||
|
if (prev.values[key] != next.values[key])
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key in next.values) {
|
||||||
|
if (!PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key) && !(key in prev.values))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUsingSearchParamsInFilters(context: SearchContextDto) {
|
||||||
|
if (!context)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const keys = Object.keys(context.values);
|
||||||
|
return keys.some(key => PROVIDER_SETTINGS.google.FilterSearchParams.includes(key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,81 @@
|
|||||||
|
.modal {
|
||||||
|
background-color: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 15px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline;
|
||||||
|
--webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
background-color: hsl(0, 0%, 84%);
|
||||||
|
display: inline;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year {
|
||||||
|
color: grey;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button>img {
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
filter: brightness(0) saturate(100%) invert(42%) sepia(75%) saturate(5087%) hue-rotate(340deg) brightness(101%) contrast(109%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 10px 25px 0;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-image {
|
||||||
|
align-self: start;
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 20px 15px;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe {
|
||||||
|
background-color: aquamarine;
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="modal">
|
||||||
|
<div class="header">
|
||||||
|
<div class="subheader">
|
||||||
|
<h2 class="title">{{data.title}}</h2>
|
||||||
|
@if (!isSeries && data.volume != null) {
|
||||||
|
<label class="volume">volume {{data.volume}}</label>
|
||||||
|
}
|
||||||
|
<label class="year">({{data.publishedAt.substring(0, 4)}})</label>
|
||||||
|
</div>
|
||||||
|
<div class="close-button"
|
||||||
|
(click)="dialogRef.close()">
|
||||||
|
<img src="/icons/close_icon.svg"
|
||||||
|
alt="close button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="result-item">
|
||||||
|
<img class="result-image"
|
||||||
|
[src]="data.thumbnail" />
|
||||||
|
<div class="result-info">
|
||||||
|
<p class="body description">{{data.desc}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="footer">
|
||||||
|
<button type="submit"
|
||||||
|
class="subscribe"
|
||||||
|
(click)="subscribe()">{{isSeries ? 'Subscribe' : 'Save'}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MediaItemModalComponent } from './media-item-modal.component';
|
||||||
|
|
||||||
|
describe('MediaItemModalComponent', () => {
|
||||||
|
let component: MediaItemModalComponent;
|
||||||
|
let fixture: ComponentFixture<MediaItemModalComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MediaItemModalComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MediaItemModalComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-media-item-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './media-item-modal.component.html',
|
||||||
|
styleUrl: './media-item-modal.component.css'
|
||||||
|
})
|
||||||
|
export class MediaItemModalComponent implements OnInit {
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
|
||||||
|
readonly data = inject<BookSearchResultDto>(MAT_DIALOG_DATA);
|
||||||
|
readonly dialogRef = inject(MatDialogRef<MediaItemModalComponent>);
|
||||||
|
|
||||||
|
isSeries: boolean = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isSeries = this.data.providerSeriesId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
console.log('data for subscribe:', this.data);
|
||||||
|
if (this.isSeries) {
|
||||||
|
this._http.post('/api/library/series', this.data)
|
||||||
|
.subscribe({
|
||||||
|
next: response => {
|
||||||
|
console.log('subscribe series:', response);
|
||||||
|
},
|
||||||
|
error: err => console.log('error on subscribing series:', err)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._http.post('/api/library/books', this.data)
|
||||||
|
.subscribe({
|
||||||
|
next: response => {
|
||||||
|
console.log('save book:', response);
|
||||||
|
},
|
||||||
|
error: err => console.log('error on saving book:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,23 @@
|
|||||||
.result-item {
|
.result-item {
|
||||||
background-color: #EEE;
|
background-color: #EEE;
|
||||||
padding: 10px;
|
padding: 15px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-image {
|
||||||
|
align-self: start;
|
||||||
|
object-fit: scale-down;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-info {
|
.result-info {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -24,20 +34,36 @@
|
|||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
margin: 0 3px;
|
margin: 3px;
|
||||||
background-color: rgb(199, 199, 199);
|
background-color: rgb(199, 199, 199);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
text-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
margin: 5px 10px;
|
margin: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-clamp: 4;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacing {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
@@ -1,5 +1,7 @@
|
|||||||
<div class="result-item">
|
<div class="result-item"
|
||||||
<img class="result-image" [src]="media().thumbnail" />
|
(click)="open()">
|
||||||
|
<img class="result-image"
|
||||||
|
[src]="media().thumbnail" />
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2 class="title">{{media().title}}</h2>
|
<h2 class="title">{{media().title}}</h2>
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="body description">{{media().desc}}</p>
|
<p class="body description">{{media().desc}}</p>
|
||||||
|
<span class="spacing"></span>
|
||||||
<p class="footer">Metadata provided by {{provider()}}</p>
|
<p class="footer">Metadata provided by {{provider()}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@@ -1,14 +1,20 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MediaItemModalComponent } from '../media-item-modal/media-item-modal.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'media-search-item',
|
selector: 'media-search-item',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
MatDialogModule,
|
||||||
|
],
|
||||||
templateUrl: './media-search-item.component.html',
|
templateUrl: './media-search-item.component.html',
|
||||||
styleUrl: './media-search-item.component.css'
|
styleUrl: './media-search-item.component.css'
|
||||||
})
|
})
|
||||||
export class MediaSearchItemComponent {
|
export class MediaSearchItemComponent {
|
||||||
|
private readonly _dialog = inject(MatDialog);
|
||||||
|
|
||||||
media = input.required<BookSearchResultDto>();
|
media = input.required<BookSearchResultDto>();
|
||||||
|
|
||||||
tags = computed(() => {
|
tags = computed(() => {
|
||||||
@@ -17,6 +23,8 @@ export class MediaSearchItemComponent {
|
|||||||
tags.push(this.media().language);
|
tags.push(this.media().language);
|
||||||
if (this.media().publisher)
|
if (this.media().publisher)
|
||||||
tags.push(this.media().publisher);
|
tags.push(this.media().publisher);
|
||||||
|
if (this.media().authors)
|
||||||
|
tags.push.apply(tags, this.media().authors.map(author => 'author: ' + author));
|
||||||
if (this.media().categories)
|
if (this.media().categories)
|
||||||
tags.push.apply(tags, this.media().categories);
|
tags.push.apply(tags, this.media().categories);
|
||||||
if (this.media().maturityRating.replaceAll('_', ' '))
|
if (this.media().maturityRating.replaceAll('_', ' '))
|
||||||
@@ -36,4 +44,10 @@ export class MediaSearchItemComponent {
|
|||||||
.map(s => s[0].toUpperCase() + s.substring(1))
|
.map(s => s[0].toUpperCase() + s.substring(1))
|
||||||
.join('-');
|
.join('-');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
this._dialog.open(MediaItemModalComponent, {
|
||||||
|
data: { ...this.media() }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user