Compare commits
8 Commits
3326b7c589
...
7ef7e372e2
Author | SHA1 | Date | |
---|---|---|---|
7ef7e372e2 | |||
71e232380b | |||
c2d06446eb | |||
1de822da14 | |||
f735d1631f | |||
89b29c58dc | |||
60e179cd13 | |||
7875c5407c |
@ -76,6 +76,10 @@ export class BooksService {
|
||||
});
|
||||
}
|
||||
|
||||
async findBooks(): Promise<BookEntity[]> {
|
||||
return await this.bookRepository.find();
|
||||
}
|
||||
|
||||
async findActualBookStatusesTrackedBy(userId: UUID, series: SeriesDto): Promise<BookStatusEntity[]> {
|
||||
return await this.bookStatusRepository.createQueryBuilder('s')
|
||||
.innerJoin('s.book', 'b')
|
||||
|
@ -282,14 +282,10 @@ export class LibraryController {
|
||||
@Get('books')
|
||||
async getBooksFromUser(
|
||||
@Request() req,
|
||||
@Body() body: SeriesDto,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.library.findBooksFromSeries({
|
||||
provider: body.provider,
|
||||
providerSeriesId: body.providerSeriesId,
|
||||
}),
|
||||
data: await this.library.findBooks(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -136,6 +136,10 @@ export class LibraryService {
|
||||
return bookId;
|
||||
}
|
||||
|
||||
async findBooks() {
|
||||
return await this.books.findBooks();
|
||||
}
|
||||
|
||||
async findBooksFromSeries(series: SeriesDto) {
|
||||
return await this.books.findBooksFromSeries(series);
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export class GoogleService {
|
||||
thumbnail: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
|
||||
url: item.volumeInfo.canonicalVolumeLink,
|
||||
provider: 'google'
|
||||
}
|
||||
};
|
||||
|
||||
const regex = this.getRegexByPublisher(result.publisher);
|
||||
const match = result.title.match(regex);
|
||||
@ -122,17 +122,17 @@ export class GoogleService {
|
||||
private getRegexByPublisher(publisher: string): RegExp {
|
||||
switch (publisher) {
|
||||
case 'J-Novel Club':
|
||||
return /^(?<title>.+?):?\sVolume\s(?<volume>\d+)$/i;
|
||||
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
|
||||
case 'Yen On':
|
||||
case 'Yen Press':
|
||||
case 'Yen Press LLC':
|
||||
return /^(?<title>.+?)(?:,?\sVol\.\s(?<volume>\d+))?\s\((?<media_type>[\w\s]+)\)$/;
|
||||
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/i;
|
||||
case 'Hanashi Media':
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/i
|
||||
case 'Regin\'s Chronicles':
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/i
|
||||
default:
|
||||
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/;
|
||||
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { SeriesDto } from './series.dto';
|
||||
|
||||
export class CreateSeriesDto extends SeriesDto {
|
||||
@ -8,5 +8,6 @@ export class CreateSeriesDto extends SeriesDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
mediaType: string;
|
||||
}
|
@ -27,6 +27,8 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/custom-theme.scss",
|
||||
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": [],
|
||||
|
2
frontend/angular-seshat/package-lock.json
generated
2
frontend/angular-seshat/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.0.0",
|
||||
"@angular/cdk": "^18.2.14",
|
||||
"@angular/common": "^18.0.0",
|
||||
"@angular/compiler": "^18.0.0",
|
||||
"@angular/core": "^18.0.0",
|
||||
@ -473,7 +474,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.14.tgz",
|
||||
"integrity": "sha512-vDyOh1lwjfVk9OqoroZAP8pf3xxKUvyl+TVR8nJxL4c5fOfUFkD7l94HaanqKSRwJcI2xiztuu92IVoHn8T33Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
@ -12,6 +12,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.0.0",
|
||||
"@angular/cdk": "^18.2.14",
|
||||
"@angular/common": "^18.0.0",
|
||||
"@angular/compiler": "^18.0.0",
|
||||
"@angular/core": "^18.0.0",
|
||||
|
@ -5,6 +5,7 @@ import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withInterceptorsFromDi, withFetch, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { LoadingInterceptor } from './shared/interceptors/loading.interceptor';
|
||||
import { TokenValidationInterceptor } from './shared/interceptors/token-validation.interceptor';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@ -17,5 +18,6 @@ export const appConfig: ApplicationConfig = {
|
||||
),
|
||||
LoadingInterceptor,
|
||||
TokenValidationInterceptor,
|
||||
provideAnimationsAsync(),
|
||||
]
|
||||
};
|
||||
|
@ -30,7 +30,8 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.results-error img, .results-end img {
|
||||
.results-error img,
|
||||
.results-end img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@ -41,3 +42,31 @@
|
||||
.filter-warning {
|
||||
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
|
||||
}
|
||||
}
|
@ -1,21 +1,24 @@
|
||||
<div class="search-content">
|
||||
<search-box
|
||||
(searchOutput)="search.next($event)"
|
||||
(filtersOutput)="filters.next($event)" />
|
||||
<div #scrollbar
|
||||
class="search-content">
|
||||
<search-box (searchOutput)="search.next($event)"
|
||||
(filtersOutput)="filters.next($event)" />
|
||||
<div class="results-box"
|
||||
(scroll)="onResultsScroll($event)">
|
||||
@for (result of results; track $index) {
|
||||
<media-search-item class="result-item"
|
||||
[media]="result" />
|
||||
}
|
||||
@if (busy()) {
|
||||
<div class="loading"></div>
|
||||
}
|
||||
@if (searchError() != null) {
|
||||
<p class="results-error">
|
||||
<img src="/icons/error_icon.svg"
|
||||
alt="error icon"
|
||||
class="filter-error" />
|
||||
{{searchError()}}
|
||||
</p>
|
||||
}
|
||||
<p class="results-error">
|
||||
<img src="/icons/error_icon.svg"
|
||||
alt="error icon"
|
||||
class="filter-error" />
|
||||
{{searchError()}}
|
||||
</p>
|
||||
}
|
||||
@if (endOfResults()) {
|
||||
<div class="results-end">
|
||||
<img src="/icons/warning_icon.svg"
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { Component, inject, NgZone, OnDestroy, signal } from '@angular/core';
|
||||
import { AuthService } from '../../services/auth/auth.service';
|
||||
import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, scan, Subscription, tap, throttleTime } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, map, scan, Subscription, tap, throttleTime } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { RedirectionService } from '../../services/redirection.service';
|
||||
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||
import { MediaSearchItemComponent } from '../media-search-item/media-search-item.component';
|
||||
import { SearchBoxComponent } from "../search-box/search-box.component";
|
||||
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({
|
||||
selector: 'add-new-page',
|
||||
standalone: true,
|
||||
@ -21,38 +28,52 @@ import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
||||
styleUrl: './add-new-page.component.css'
|
||||
})
|
||||
export class AddNewPageComponent implements OnDestroy {
|
||||
private readonly _auth = inject(AuthService);
|
||||
private readonly _http = inject(HttpClient);
|
||||
private readonly _redirect = inject(RedirectionService);
|
||||
private readonly _subscriptions: Subscription[] = [];
|
||||
private readonly _zone = inject(NgZone);
|
||||
|
||||
@ViewChild('scrollbar') private readonly searchContentRef: ElementRef<Element> = {} as ElementRef;
|
||||
|
||||
search = new BehaviorSubject<string>('');
|
||||
filters = new BehaviorSubject<SearchContextDto>(new SearchContextDto());
|
||||
page = new BehaviorSubject<number>(0);
|
||||
results: BookSearchResultDto[] = [];
|
||||
resultsPerPage = signal<number>(10);
|
||||
busy = signal<boolean>(false);
|
||||
endOfResults = signal<boolean>(false);
|
||||
searchError = signal<string | null>(null);
|
||||
|
||||
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.
|
||||
this._subscriptions.push(
|
||||
combineLatest({
|
||||
search: this.search.pipe(
|
||||
filter(value => value != null),
|
||||
),
|
||||
filters: this.filters,
|
||||
filters: this.filters.pipe(
|
||||
map(filters => ({ values: { ...filters.values } }))
|
||||
),
|
||||
page: this.page.pipe(
|
||||
throttleTime(1500, undefined, { leading: true, trailing: true }),
|
||||
throttleTime(3000, undefined, { leading: false, trailing: true }),
|
||||
),
|
||||
}).pipe(
|
||||
filter(entry => entry.search!.length > 1),
|
||||
throttleTime(1000, undefined, { leading: false, trailing: true }),
|
||||
debounceTime(1000),
|
||||
filter(entry => entry.search.length > 1 || this.isUsingSearchParamsInFilters(entry.filters)),
|
||||
scan((acc, next) => {
|
||||
// New searches means resetting to page 0.
|
||||
if (acc.search != next.search) {
|
||||
// Different search or filters means resetting to page 0.
|
||||
const searchChanged = acc.search != next.search && next.search && next.search.length > 1;
|
||||
const filtersChanged = this.hasFiltersMismatched(acc.filters, next.filters);
|
||||
if (searchChanged || filtersChanged) {
|
||||
this.results = [];
|
||||
return {
|
||||
...next,
|
||||
@ -60,37 +81,45 @@ export class AddNewPageComponent implements OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
// Ignore further page searching if:
|
||||
// - there are no more results;
|
||||
// - user is still busy loading new pages;
|
||||
// - only max results filter changed.
|
||||
if (this.endOfResults() || this.busy() || acc.filters.values['maxResults'] != next.filters.values['maxResults']) {
|
||||
return {
|
||||
...next,
|
||||
page: -1,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep searching the same page until error stops.
|
||||
if (this.searchError() != null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Ignore further searching on the same search term.
|
||||
if (this.endOfResults()) {
|
||||
return {
|
||||
...next,
|
||||
page: -1,
|
||||
};
|
||||
}
|
||||
|
||||
// Next page.
|
||||
return {
|
||||
...next,
|
||||
page: Math.min(acc.page + 1, next.page),
|
||||
};
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
filter(entry => entry.page >= 0),
|
||||
tap(_ => this.endOfResults.set(false)),
|
||||
distinctUntilChanged(),
|
||||
).subscribe((entry) => {
|
||||
this.busy.set(true);
|
||||
this.endOfResults.set(false);
|
||||
if (this.searchContentRef) {
|
||||
this.searchContentRef.nativeElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
this._http.get('/api/providers/search',
|
||||
{
|
||||
params: {
|
||||
...this.filters.value.values,
|
||||
...entry.filters.values,
|
||||
provider: 'google',
|
||||
search: entry.search!,
|
||||
startIndex: entry.page * this.resultsPerPage(),
|
||||
}
|
||||
},
|
||||
}
|
||||
).subscribe({
|
||||
next: (results: any) => {
|
||||
@ -99,8 +128,11 @@ export class AddNewPageComponent implements OnDestroy {
|
||||
if (results.length < this.resultsPerPage()) {
|
||||
this.endOfResults.set(true);
|
||||
}
|
||||
this.searchError.set(null);
|
||||
this.busy.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.busy.set(false);
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
if (err.status == 400) {
|
||||
this.searchError.set('Something went wrong when Google received the request.');
|
||||
@ -115,8 +147,8 @@ export class AddNewPageComponent implements OnDestroy {
|
||||
}
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@ -128,7 +160,7 @@ export class AddNewPageComponent implements OnDestroy {
|
||||
const limit = scroll.scrollHeight - scroll.clientHeight;
|
||||
|
||||
// Prevent page changes when:
|
||||
// - new search is happening (emptying results);
|
||||
// - new search is happening (emptied results);
|
||||
// - still scrolling through current content.
|
||||
if (scroll.scrollTop == 0 || scroll.scrollTop < limit - 25) {
|
||||
return;
|
||||
@ -136,4 +168,34 @@ export class AddNewPageComponent implements OnDestroy {
|
||||
|
||||
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 {
|
||||
background-color: #EEE;
|
||||
padding: 10px;
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-image {
|
||||
align-self: start;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
@ -24,20 +34,36 @@
|
||||
|
||||
.tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0 5px;
|
||||
margin: 0 3px;
|
||||
margin: 3px;
|
||||
background-color: rgb(199, 199, 199);
|
||||
border-radius: 4px;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 5px 10px;
|
||||
}
|
||||
|
||||
.description {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
line-clamp: 4;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: right;
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
<div class="result-item">
|
||||
<img class="result-image" [src]="media().thumbnail" />
|
||||
<div class="result-item"
|
||||
(click)="open()">
|
||||
<img class="result-image"
|
||||
[src]="media().thumbnail" />
|
||||
<div class="result-info">
|
||||
<div class="header">
|
||||
<h2 class="title">{{media().title}}</h2>
|
||||
@ -13,6 +15,7 @@
|
||||
}
|
||||
</div>
|
||||
<p class="body description">{{media().desc}}</p>
|
||||
<span class="spacing"></span>
|
||||
<p class="footer">Metadata provided by {{provider()}}</p>
|
||||
</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 { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MediaItemModalComponent } from '../media-item-modal/media-item-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'media-search-item',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [
|
||||
MatDialogModule,
|
||||
],
|
||||
templateUrl: './media-search-item.component.html',
|
||||
styleUrl: './media-search-item.component.css'
|
||||
})
|
||||
export class MediaSearchItemComponent {
|
||||
private readonly _dialog = inject(MatDialog);
|
||||
|
||||
media = input.required<BookSearchResultDto>();
|
||||
|
||||
tags = computed(() => {
|
||||
@ -17,6 +23,8 @@ export class MediaSearchItemComponent {
|
||||
tags.push(this.media().language);
|
||||
if (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)
|
||||
tags.push.apply(tags, this.media().categories);
|
||||
if (this.media().maturityRating.replaceAll('_', ' '))
|
||||
@ -36,4 +44,10 @@ export class MediaSearchItemComponent {
|
||||
.map(s => s[0].toUpperCase() + s.substring(1))
|
||||
.join('-');
|
||||
});
|
||||
|
||||
open(): void {
|
||||
this._dialog.open(MediaItemModalComponent, {
|
||||
data: { ...this.media() }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -61,6 +61,12 @@ input:focus {
|
||||
box-shadow: 0 0 4px 3px rgba(31, 128, 255, 0.5);
|
||||
}
|
||||
|
||||
.icon-wrapper>img, .icon-button>img {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
filter: brightness(0) saturate(100%) invert(44%) sepia(0%) saturate(167%) hue-rotate(154deg) brightness(90%) contrast(87%);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -24,7 +24,8 @@
|
||||
[class.collapsed]="!filtersEnabled.value">
|
||||
<div class="select-wrapper">
|
||||
<label for="languageSelect">Language</label>
|
||||
<select name="languageSelect"
|
||||
<select #languageSelect
|
||||
name="languageSelect"
|
||||
(change)="updateFilters('langRestrict', $event.target)">
|
||||
<option value="">All</option>
|
||||
@for (language of provider.languages | keyvalue; track language.key) {
|
||||
@ -34,7 +35,8 @@
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<label for="orderBySelect">Order By</label>
|
||||
<select name="orderBySelect"
|
||||
<select #orderBySelect
|
||||
name="orderBySelect"
|
||||
(change)="updateFilters('orderBy', $event.target)">
|
||||
<option value="relevance">Relevance</option>
|
||||
<option value="newest">Newest</option>
|
||||
@ -42,7 +44,8 @@
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<label for="resultsSizeSelect">Results Size</label>
|
||||
<select name="resultsSizeSelect"
|
||||
<select #resultsSizeSelect
|
||||
name="resultsSizeSelect"
|
||||
(change)="updateFilters('maxResults', $event.target)">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
@ -51,8 +54,18 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-wrapper">
|
||||
<label for="resultsSizeSelect">ISBN</label>
|
||||
<input type="text"
|
||||
<label for="authorInput">Author</label>
|
||||
<input #authorInput
|
||||
name="authorInput"
|
||||
type="text"
|
||||
placeholder="J. R. R. Tolkien"
|
||||
[formControl]="author" />
|
||||
</div>
|
||||
<div class="text-wrapper">
|
||||
<label for="isbnInput">ISBN</label>
|
||||
<input #isbnInput
|
||||
name="isbnInput"
|
||||
type="text"
|
||||
placeholder="ISBN-10 or ISBN-13"
|
||||
[formControl]="isbn" />
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, inject, NgZone, OnDestroy, output } from '@angular/core';
|
||||
import { AfterViewInit, Component, ElementRef, inject, NgZone, OnDestroy, output, ViewChild } from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
||||
import { filter, Subscription } from 'rxjs';
|
||||
import { filter, map, Subscription } from 'rxjs';
|
||||
import { ConfigService } from '../../services/config.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@ -15,10 +15,16 @@ import { CommonModule } from '@angular/common';
|
||||
templateUrl: './search-box.component.html',
|
||||
styleUrl: './search-box.component.css'
|
||||
})
|
||||
export class SearchBoxComponent implements OnDestroy {
|
||||
export class SearchBoxComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly _subscriptions: Subscription[] = [];
|
||||
private readonly _zone = inject(NgZone);
|
||||
|
||||
@ViewChild('languageSelect') private readonly languageRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||
@ViewChild('orderBySelect') private readonly orderByRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||
@ViewChild('resultsSizeSelect') private readonly resultsSizeRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||
@ViewChild('authorInput') private readonly authorRef: ElementRef<HTMLInputElement> = {} as ElementRef;
|
||||
@ViewChild('isbnInput') private readonly isbnRef: ElementRef<HTMLInputElement> = {} as ElementRef;
|
||||
|
||||
config = inject(ConfigService).config;
|
||||
filtersEnabled = new FormControl<boolean>(false);
|
||||
search = new FormControl<string>('');
|
||||
@ -26,23 +32,54 @@ export class SearchBoxComponent implements OnDestroy {
|
||||
filters = new SearchContextDto();
|
||||
filtersOutput = output<SearchContextDto>();
|
||||
isbn = new FormControl<string>('');
|
||||
author = new FormControl<string>('');
|
||||
|
||||
constructor() {
|
||||
this._zone.runOutsideAngular(() => {
|
||||
this._subscriptions.push(
|
||||
this.search.valueChanges.pipe(
|
||||
filter(value => value != null),
|
||||
map(value => value!.trim()),
|
||||
filter(value => value.length > 0),
|
||||
).subscribe((value) => this.searchOutput.emit(value!))
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
this.author.valueChanges.pipe(
|
||||
filter(value => value != null),
|
||||
map(value => value!.trim()),
|
||||
filter(value => value.length > 0),
|
||||
).subscribe((value) => this.updateFilters('inauthor', { value: value }))
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
this.isbn.valueChanges.pipe(
|
||||
filter(value => value != null),
|
||||
map(value => value!.trim()),
|
||||
map(value => value.length == 10 || value.length >= 13 && value.length <= 15 ? value : ''),
|
||||
filter(value => value.length > 0),
|
||||
).subscribe((value) => this.updateFilters('isbn', { value: value }))
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
if (this.languageRef && this.languageRef.nativeElement.value)
|
||||
this.updateFilters('langRestrict', this.languageRef.nativeElement);
|
||||
if (this.orderByRef && this.orderByRef.nativeElement.value)
|
||||
this.updateFilters('orderBy', this.orderByRef.nativeElement);
|
||||
if (this.resultsSizeRef && this.resultsSizeRef.nativeElement.value)
|
||||
this.updateFilters('maxResults', this.resultsSizeRef.nativeElement);
|
||||
if (this.authorRef && this.authorRef.nativeElement.value)
|
||||
this.updateFilters('inauthor', this.authorRef.nativeElement);
|
||||
if (this.isbnRef && this.isbnRef.nativeElement.value)
|
||||
this.updateFilters('isbn', this.isbnRef.nativeElement);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
get provider() {
|
||||
switch (this.config.providers.default) {
|
||||
case 'google': return this.config.providers.google;
|
||||
@ -51,15 +88,11 @@ export class SearchBoxComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
updateFilters(key: string, value: any) {
|
||||
if (key == 'langRestrict' && value == '') {
|
||||
if (!value) {
|
||||
delete this.filters.values[key];
|
||||
} else {
|
||||
this.filters.values[key] = value.value;
|
||||
}
|
||||
this.filtersOutput.emit(this.filters);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
36
frontend/angular-seshat/src/custom-theme.scss
Normal file
36
frontend/angular-seshat/src/custom-theme.scss
Normal file
@ -0,0 +1,36 @@
|
||||
|
||||
// Custom Theming for Angular Material
|
||||
// For more information: https://material.angular.io/guide/theming
|
||||
@use '@angular/material' as mat;
|
||||
// Plus imports for other components in your app.
|
||||
|
||||
// Include the common styles for Angular Material. We include this here so that you only
|
||||
// have to load a single css file for Angular Material in your app.
|
||||
// Be sure that you only ever include this mixin once!
|
||||
@include mat.core();
|
||||
|
||||
// Define the theme object.
|
||||
$angular-seshat-theme: mat.define-theme((
|
||||
color: (
|
||||
theme-type: light,
|
||||
primary: mat.$azure-palette,
|
||||
tertiary: mat.$blue-palette,
|
||||
),
|
||||
density: (
|
||||
scale: 0,
|
||||
)
|
||||
));
|
||||
|
||||
// Include theme styles for core and each component used in your app.
|
||||
// Alternatively, you can import and @include the theme mixins for each component
|
||||
// that you are using.
|
||||
:root {
|
||||
@include mat.all-component-themes($angular-seshat-theme);
|
||||
}
|
||||
|
||||
// Comment out the line below if you want to use the pre-defined typography utility classes.
|
||||
// For more information: https://material.angular.io/guide/typography#using-typography-styles-in-your-application.
|
||||
// @include mat.typography-hierarchy($angular-seshat-theme);
|
||||
|
||||
// Comment out the line below if you want to use the deprecated `color` inputs.
|
||||
// @include mat.color-variants-backwards-compatibility($angular-seshat-theme);
|
@ -6,8 +6,10 @@
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -3,3 +3,5 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
|
Reference in New Issue
Block a user