Added media item modal when user clicks on an item to save/subscribe a new media.

This commit is contained in:
Tom
2025-06-26 14:31:16 +00:00
parent 89b29c58dc
commit f735d1631f
8 changed files with 305 additions and 26 deletions

View File

@@ -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));
}
} }

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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)
});
}
}
}

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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() }
});
}
} }