Smart Filters & Dashboard Customization (#2282)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-09-12 11:24:47 -07:00 committed by GitHub
parent 3d501c9532
commit 84f85b4f24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 7149 additions and 555 deletions

View file

@ -0,0 +1,14 @@
import {Observable} from "rxjs";
import {StreamType} from "./stream-type.enum";
export interface DashboardStream {
id: number;
name: string;
isProvided: boolean;
api: Observable<any[]>;
smartFilterId: number;
smartFilterEncoded?: string;
streamType: StreamType;
order: number;
visible: boolean;
}

View file

@ -0,0 +1,7 @@
export enum StreamType {
OnDeck = 1,
RecentlyUpdated = 2,
NewlyAdded = 3,
SmartFilter = 4,
MoreInGenre = 5
}

View file

@ -0,0 +1,3 @@
export interface DashboardUpdateEvent {
userId: number;
}

View file

@ -1,4 +1,4 @@
export interface LibraryModifiedEvent {
libraryId: number;
action: 'create' | 'delelte';
}
action: 'create' | 'delete';
}

View file

@ -26,7 +26,8 @@ export enum FilterField
ReleaseYear = 22,
ReadTime = 23,
Path = 24,
FilePath = 25
FilePath = 25,
WantToRead = 26
}
export const allFields = Object.keys(FilterField)

View file

@ -0,0 +1,5 @@
export interface SmartFilter {
id: number;
name: string;
filter: string;
}

View file

@ -58,7 +58,7 @@ export class AccountService {
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshAccount()))
.subscribe(() => {});
}
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);

View file

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import {TextResonse} from "../_types/text-response";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
@Injectable({
providedIn: 'root'
})
export class DashboardService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getDashboardStreams(visibleOnly = true) {
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly);
}
updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse);
}
updateDashboardStream(stream: DashboardStream) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse);
}
createDashboardStream(smartFilterId: number) {
return this.httpClient.post<DashboardStream>(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
}
}

View file

@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
@Injectable({
providedIn: 'root'
})
export class FilterService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
saveFilter(filter: SeriesFilterV2) {
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
}
getAllFilters() {
return this.httpClient.get<Array<SmartFilter>>(this.baseUrl + 'filter');
}
deleteFilter(filterId: number) {
return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId);
}
}

View file

@ -7,6 +7,7 @@ import { NotificationProgressEvent } from '../_models/events/notification-progre
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -82,6 +83,10 @@ export enum EVENTS {
* A scrobbling token has expired
*/
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
/**
* User's dashboard needs to be re-rendered
*/
DashboardUpdate = 'DashboardUpdate'
}
export interface Message<T> {
@ -109,7 +114,6 @@ export class MessageHubService {
*/
public onlineUsers$ = this.onlineUsersSource.asObservable();
isAdmin: boolean = false;
constructor() {}
@ -181,6 +185,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
console.log('dashboard update event came in')
this.messagesSource.next({
event: EVENTS.DashboardUpdate,
payload: resp.body as DashboardUpdateEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({

View file

@ -23,14 +23,16 @@
</div>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
<div class="review-user">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
</div>
<span class="review-score" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}</span>
</div>
</div>
</div>

View file

@ -41,5 +41,9 @@
}
.card-footer {
font-size: 13px
font-size: 13px;
display: flex;
max-width: 305px;
justify-content: space-between;
margin: 0 auto;
}

View file

@ -1,36 +1,86 @@
<app-side-nav-companion-bar></app-side-nav-companion-bar>
<ng-container *transloco="let t; read: 'dashboard'">
<ng-container *ngIf="libraries$ | async as libraries">
<ng-container *ngIf="libraries.length === 0 && !isLoading">
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
</div>
</ng-container>
<ng-container *ngIf="libraries$ | async as libraries">
<ng-container *ngIf="libraries.length === 0 && !isLoadingAdmin">
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
</div>
</ng-container>
</ng-container>
<ng-container *ngFor="let stream of streams">
<ng-container [ngSwitch]="stream.streamType">
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.RecentlyUpdated" [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.NewlyAdded" [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.SmartFilter" [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.MoreInGenre" [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
</ng-container>
<app-carousel-reel [items]="inProgress" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
<ng-template #carouselItem let-item>
<ng-template #smartFilter let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="false"
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #onDeck let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" [isOnDeck]="true"
(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<app-carousel-reel [items]="recentlyUpdatedSeries" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
<ng-template #carouselItem let-item>
<ng-template #recentlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
<ng-template #carouselItem let-item>
<app-card-item [entity]="item" [title]="item.seriesName" [suppressLibraryLink]="libraryId !== 0" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #newlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<ng-template #moreInGenre let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-template>
<app-carousel-reel [items]="recentlyAddedSeries" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<app-loading [loading]="isLoadingDashboard"></app-loading>
</ng-container>

View file

@ -1,16 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {Router, RouterLink} from '@angular/router';
import {Observable, of, ReplaySubject} from 'rxjs';
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
import {map, shareReplay, take, tap, throttleTime} from 'rxjs/operators';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
import {Library} from 'src/app/_models/library';
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
import {Series} from 'src/app/_models/series';
import {SortField} from 'src/app/_models/metadata/series-filter';
import {SeriesGroup} from 'src/app/_models/series-group';
import {AccountService} from 'src/app/_services/account.service';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
@ -20,13 +17,21 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CardItemComponent} from '../../cards/card-item/card-item.component';
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
import {AsyncPipe, NgIf} from '@angular/common';
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {DashboardService} from "../../_services/dashboard.service";
import {MetadataService} from "../../_services/metadata.service";
import {RecommendationService} from "../../_services/recommendation.service";
import {Genre} from "../../_models/metadata/genre";
import {DashboardStream} from "../../_models/dashboard/dashboard-stream";
import {StreamType} from "../../_models/dashboard/stream-type.enum";
import {SeriesRemovedEvent} from "../../_models/events/series-removed-event";
import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({
selector: 'app-dashboard',
@ -34,7 +39,8 @@ import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, CardItemComponent, AsyncPipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent]
})
export class DashboardComponent implements OnInit {
@ -44,13 +50,14 @@ export class DashboardComponent implements OnInit {
@Input() libraryId: number = 0;
libraries$: Observable<Library[]> = of([]);
isLoading = true;
isLoadingAdmin = true;
isLoadingDashboard = true;
isAdmin$: Observable<boolean> = of(false);
recentlyUpdatedSeries: SeriesGroup[] = [];
inProgress: Series[] = [];
recentlyAddedSeries: Series[] = [];
streams: Array<DashboardStream> = [];
genre: Genre | undefined;
refreshStreams$ = new Subject<void>();
/**
* We use this Replay subject to slow the amount of times we reload the UI
@ -58,112 +65,133 @@ export class DashboardComponent implements OnInit {
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
private readonly destroyRef = inject(DestroyRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly metadataService = inject(MetadataService);
private readonly recommendationService = inject(RecommendationService);
protected readonly StreamType = StreamType;
constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private router: Router,
private titleService: Title, public imageService: ImageService,
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef) {
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res.event === EVENTS.SeriesAdded) {
const seriesAddedEvent = res.payload as SeriesAddedEvent;
private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef,
private dashboardService: DashboardService) {
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
this.cdRef.markForCheck();
});
} else if (res.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
this.loadDashboard();
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
this.cdRef.markForCheck();
} else if (res.event === EVENTS.ScanSeries) {
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
this.loadRecentlyAdded$.next();
}
});
this.refreshStreams$.pipe(takeUntilDestroyed(this.destroyRef), throttleTime(10_000),
tap(() => {
this.loadDashboard()
}))
.subscribe();
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay()
);
this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.loadRecentlyUpdated();
this.loadRecentlyAddedSeries();
this.cdRef.markForCheck();
});
// TODO: Solve how Websockets will work with these dyanamic streams
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res.event === EVENTS.DashboardUpdate) {
console.log('dashboard update triggered')
this.refreshStreams$.next();
} else if (res.event === EVENTS.SeriesAdded) {
// const seriesAddedEvent = res.payload as SeriesAddedEvent;
// this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
// if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
// this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
// this.cdRef.markForCheck();
// });
this.refreshStreams$.next();
} else if (res.event === EVENTS.SeriesRemoved) {
//const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
//
// this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
// this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
// this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
// this.cdRef.markForCheck();
this.refreshStreams$.next();
} else if (res.event === EVENTS.ScanSeries) {
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
this.loadRecentlyAdded$.next();
this.refreshStreams$.next();
}
});
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay({bufferSize: 1, refCount: true})
);
}
ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
this.isLoading = true;
this.titleService.setTitle('Kavita');
this.isLoadingAdmin = true;
this.cdRef.markForCheck();
this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => {
this.isLoading = false;
this.isLoadingAdmin = false;
this.cdRef.markForCheck();
}));
this.reloadSeries();
}
reloadSeries() {
this.loadOnDeck();
this.loadRecentlyUpdated();
this.loadRecentlyAddedSeries();
}
reloadInProgress(series: Series | number) {
this.loadOnDeck();
}
loadOnDeck() {
let api = this.seriesService.getOnDeck(0, 1, 30);
if (this.libraryId > 0) {
api = this.seriesService.getOnDeck(this.libraryId, 1, 30);
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
this.inProgress = updatedSeries.result;
this.cdRef.markForCheck();
});
}
loadRecentlyAddedSeries() {
let api = this.seriesService.getRecentlyAdded(1, 30);
if (this.libraryId > 0) {
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
api = this.seriesService.getRecentlyAdded(1, 30, filter);
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
this.recentlyAddedSeries = updatedSeries.result;
this.cdRef.markForCheck();
});
}
loadRecentlyUpdated() {
let api = this.seriesService.getRecentlyUpdatedSeries();
if (this.libraryId > 0) {
api = this.seriesService.getRecentlyUpdatedSeries();
}
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(updatedSeries => {
this.recentlyUpdatedSeries = updatedSeries.filter(group => {
if (this.libraryId === 0) return true;
return group.libraryId === this.libraryId;
loadDashboard() {
this.isLoadingDashboard = true;
this.cdRef.markForCheck();
this.dashboardService.getDashboardStreams().subscribe(streams => {
this.streams = streams;
this.streams.forEach(s => {
switch (s.streamType) {
case StreamType.OnDeck:
s.api = this.seriesService.getOnDeck(0, 1, 20)
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.NewlyAdded:
s.api = this.seriesService.getRecentlyAdded(1, 20)
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.RecentlyUpdated:
s.api = this.seriesService.getRecentlyUpdatedSeries();
break;
case StreamType.SmartFilter:
s.api = this.seriesService.getAllSeriesV2(0, 20, this.filterUtilityService.decodeSeriesFilter(s.smartFilterEncoded!))
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break;
case StreamType.MoreInGenre:
s.api = this.metadataService.getAllGenres().pipe(
map(genres => {
this.genre = genres[Math.floor(Math.random() * genres.length)];
return this.genre;
}),
switchMap(genre => this.recommendationService.getMoreIn(0, genre.id, 0, 30)),
map(p => p.result),
takeUntilDestroyed(this.destroyRef),
shareReplay({bufferSize: 1, refCount: true})
);
break;
}
});
this.isLoadingDashboard = false;
this.cdRef.markForCheck();
});
}
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
reloadStream(streamId: number) {
const index = this.streams.findIndex(s => s.id === streamId);
if (index < 0) return;
this.streams[index] = {...this.streams[index]};
console.log('swapped out stream: ', this.streams[index]);
this.cdRef.detectChanges();
}
async handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
}
async handleFilterSectionClick(stream: DashboardStream) {
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
}
handleSectionClick(sectionTitle: string) {
@ -180,7 +208,7 @@ export class DashboardComponent implements OnInit {
} else if (sectionTitle.toLowerCase() === 'on deck') {
const params: any = {};
params['page'] = 1;
params['title'] = 'On Deck';
params['title'] = translate('dashboard.on-deck-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
@ -190,16 +218,23 @@ export class DashboardComponent implements OnInit {
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}else if (sectionTitle.toLowerCase() === 'newly added series') {
} else if (sectionTitle.toLowerCase() === 'newly added series') {
const params: any = {};
params['page'] = 1;
params['title'] = 'Newly Added';
params['title'] = translate('dashboard.recently-added-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.Created;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
} else if (sectionTitle.toLowerCase() === 'more in genre') {
const params: any = {};
params['page'] = 1;
params['title'] = translate('more-in-genre-title', {genre: this.genre?.title});
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}
}

View file

@ -4,45 +4,26 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<span>{{libraryName}}</span>
</h2>
<div main>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink>
<span class="d-none d-sm-flex align-items-center"><i class="fa {{tab.icon}} me-1" style="padding-right: 5px;" aria-hidden="true"></i> {{t('library-detail.' + tab.title) | sentenceCase}}</span>
<span class="d-flex d-sm-none">
<i class="fa {{tab.icon}}" aria-hidden="true"></i>
</span>
</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.title === 'recommended-tab'">
<app-library-recommended [libraryId]="libraryId"></app-library-recommended>
</ng-container>
<ng-container *ngIf="tab.title === 'library-tab'">
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-container>
</ng-template>
</li>
</ul>
</div>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<div [ngbNavOutlet]="nav"></div>
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-container>

View file

@ -33,7 +33,6 @@ import {SentenceCasePipe} from '../pipe/sentence-case.pipe';
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component';
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import {
@ -52,7 +51,8 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf, LibraryRecommendedComponent, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
})
export class LibraryDetailComponent implements OnInit {
@ -284,9 +284,5 @@ export class LibraryDetailComponent implements OnInit {
});
}
seriesClicked(series: Series) {
this.router.navigate(['library', this.libraryId, 'series', series.id]);
}
trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`;
}

View file

@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
import { LibraryDetailComponent } from './library-detail.component';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
@ -27,7 +26,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
SeriesCardComponent,
BulkOperationsComponent,
SideNavCompanionBarComponent,
LibraryDetailComponent, LibraryRecommendedComponent
LibraryDetailComponent,
]
})
export class LibraryDetailModule { }

View file

@ -1,61 +0,0 @@
<ng-container *transloco="let t; read: 'library-recommended'">
<ng-container *ngIf="all$ | async as all">
<p *ngIf="all.length === 0">
{{t('no-data')}}
</p>
</ng-container>
<ng-container *ngIf="onDeck$ | async as onDeck">
<app-carousel-reel [items]="onDeck" [title]="t('on-deck')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickReads$ | async as quickReads">
<app-carousel-reel [items]="quickReads" [title]="t('quick-reads')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickCatchups$ | async as quickCatchups">
<app-carousel-reel [items]="quickCatchups" [title]="t('quick-catchups')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="highlyRated$ | async as highlyRated">
<app-carousel-reel [items]="highlyRated" [title]="t('highly-rated')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="rediscover$ | async as rediscover">
<app-carousel-reel [items]="rediscover" [title]="t('rediscover')">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="genre$ | async as genre">
<ng-container *ngIf="moreIn$ | async as moreIn">
<ng-container *ngIf="moreIn.length > 1">
<app-carousel-reel [items]="moreIn" [title]="t('more-in-genre', {genre: genre.title})">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-container>
</ng-container>
</ng-container>

View file

@ -1,91 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import { filter, map, merge, Observable, shareReplay } from 'rxjs';
import { Genre } from 'src/app/_models/metadata/genre';
import { Series } from 'src/app/_models/series';
import { MetadataService } from 'src/app/_services/metadata.service';
import { RecommendationService } from 'src/app/_services/recommendation.service';
import { SeriesService } from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SeriesCardComponent } from '../../cards/series-card/series-card.component';
import { CarouselReelComponent } from '../../carousel/_components/carousel-reel/carousel-reel.component';
import { NgIf, AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
selector: 'app-library-recommended',
templateUrl: './library-recommended.component.html',
styleUrls: ['./library-recommended.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, CarouselReelComponent, SeriesCardComponent, AsyncPipe, TranslocoDirective]
})
export class LibraryRecommendedComponent implements OnInit {
@Input() libraryId: number = 0;
private readonly destroyRef = inject(DestroyRef);
quickReads$!: Observable<Series[]>;
quickCatchups$!: Observable<Series[]>;
highlyRated$!: Observable<Series[]>;
onDeck$!: Observable<Series[]>;
rediscover$!: Observable<Series[]>;
moreIn$!: Observable<Series[]>;
genre$!: Observable<Genre>;
all$!: Observable<any>;
constructor(private recommendationService: RecommendationService, private seriesService: SeriesService,
private metadataService: MetadataService) { }
ngOnInit(): void {
this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.rediscover$ = this.recommendationService.getRediscover(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.onDeck$ = this.seriesService.getOnDeck(this.libraryId, 0, 30)
.pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
this.genre$ = this.metadataService.getAllGenres([this.libraryId]).pipe(
takeUntilDestroyed(this.destroyRef),
map(genres => genres[Math.floor(Math.random() * genres.length)]),
shareReplay()
);
this.genre$.subscribe(genre => {
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id, 0, 30).pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay());
});
this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntilDestroyed(this.destroyRef));
}
reloadInProgress(series: Series | number) {
if (Number.isInteger(series)) {
if (!series) {return;}
}
// If the update to Series doesn't affect the requirement to be in this stream, then ignore update request
const seriesObj = (series as Series);
if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) {
return;
}
this.quickReads$ = this.quickReads$.pipe(filter(series => !series.includes(seriesObj)));
this.quickCatchups$ = this.quickCatchups$.pipe(filter(series => !series.includes(seriesObj)));
}
}

View file

@ -22,6 +22,9 @@
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>

View file

@ -30,6 +30,7 @@ enum PredicateType {
Text = 1,
Number = 2,
Dropdown = 3,
Boolean = 4
}
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
@ -41,6 +42,7 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
];
const BooleanFields = [FilterField.WantToRead]
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
@ -69,6 +71,9 @@ const DropdownComparisons = [FilterComparison.Equal,
FilterComparison.Contains,
FilterComparison.NotContains,
FilterComparison.MustContains];
const BooleanComparisons = [
FilterComparison.Equal
]
@Component({
selector: 'app-metadata-row-filter',
@ -155,7 +160,11 @@ export class MetadataFilterRowComponent implements OnInit {
stmt.value = stmt.value + '';
}
if (!stmt.value && stmt.field !== FilterField.SeriesName) return;
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
});
@ -172,6 +181,8 @@ export class MetadataFilterRowComponent implements OnInit {
if (StringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (BooleanFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed || val.includes(',')) {
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
@ -270,6 +281,16 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (BooleanFields.includes(inputVal)) {
this.validComparisons$.next(BooleanComparisons);
this.predicateType$.next(PredicateType.Boolean);
if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue(false);
}
return;
}
if (DropdownFields.includes(inputVal)) {
let comps = [...DropdownComparisons];
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {

View file

@ -62,6 +62,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.path');
case FilterField.FilePath:
return translate('filter-field-pipe.file-path');
case FilterField.WantToRead:
return translate('filter-field-pipe.want-to-read');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View file

@ -7,4 +7,5 @@ export class FilterSettings {
* The number of statements that can be on the filter. Set to 1 to disable adding more.
*/
statementLimit: number = 0;
saveDisabled: boolean = false;
}

View file

@ -48,6 +48,11 @@
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
</select>
</div>
<div class="col-md-3 col-sm-10">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
</div>
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
@ -58,12 +63,18 @@
</ng-template>
<ng-template #buttons>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-2 col-sm-6 mt-4">
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
</div>
<div class="col-md-2 col-sm-6 mt-4">
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
<!-- TODO: Icon here -->
{{t('save')}}
</button>
</div>
</ng-template>
</ng-container>

View file

@ -11,8 +11,7 @@ import {
Output
} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import {Library} from '../_models/library';
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
@ -23,10 +22,14 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
import {DrawerComponent} from '../shared/drawer/drawer.component';
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
import {translate, TranslocoModule} from "@ngneat/transloco";
import {SortFieldPipe} from "../pipe/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
import {MetadataService} from "../_services/metadata.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-metadata-filter',
@ -81,9 +84,10 @@ export class MetadataFilterComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
constructor(public toggleService: ToggleService) {}
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
ngOnInit(): void {
if (this.filterSettings === undefined) {
@ -141,7 +145,8 @@ export class MetadataFilterComponent implements OnInit {
this.sortGroup = new FormGroup({
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
limitTo: new FormControl(this.filterV2?.limitTo || 0, []),
name: new FormControl(this.filterV2?.name || '', [])
});
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
@ -153,6 +158,7 @@ export class MetadataFilterComponent implements OnInit {
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
this.cdRef.markForCheck();
});
@ -190,6 +196,15 @@ export class MetadataFilterComponent implements OnInit {
this.cdRef.markForCheck();
}
save() {
if (!this.filterV2) return;
this.filterV2.name = this.sortGroup.get('name')?.value;
this.filterService.saveFilter(this.filterV2).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-updated'));
this.apply();
})
}
toggleSelected() {
this.toggleService.toggle();
this.cdRef.markForCheck();

View file

@ -41,7 +41,7 @@
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item')}}</span>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
</div>
</div>

View file

@ -1,28 +1,27 @@
.example-list {
min-width: 500px;
width: 100%;
max-width: 100%;
min-height: 60px;
display: block;
border-radius: 4px;
overflow: hidden;
}
.example-box {
margin: 5px 0;
display: flex;
flex-direction: row;
box-sizing: border-box;
font-size: 14px;
max-height: 140px;
height: 140px;
.drag-handle {
cursor: move;
font-size: 24px;
// TODO: This needs to be calculation based
margin-top: 215%;
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
@ -30,19 +29,20 @@
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.example-box:last-child {
border: none;
margin-bottom: 20px;
}
.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
@ -70,4 +70,4 @@
virtual-scroller.empty {
display: none;
}
}

View file

@ -178,7 +178,7 @@ export class ReadingListDetailComponent implements OnInit {
orderUpdated(event: IndexUpdateEvent) {
if (!this.readingList) return;
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(() => { /* No Operation */ });
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
itemRemoved(item: ReadingListItem, position: number) {

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'reading-list-item'">
<div class="d-flex flex-row g-0 mb-2">
<div class="d-flex flex-row g-0 mb-2 reading-list-item">
<div class="pe-2">
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<ng-container *ngIf="item.pagesRead === 0 && item.pagesTotal > 0">

View file

@ -1,5 +1,10 @@
$image-height: 125px;
.reading-list-item {
max-height: 140px;
height: 140px;
}
.progress-banner {
height: 5px;
@ -9,12 +14,6 @@ $image-height: 125px;
}
}
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}
.badge-container {
border-radius: 4px;
display: block;
@ -34,4 +33,4 @@ $image-height: 125px;
border-style: solid;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent;
}
}

View file

@ -107,6 +107,49 @@ export class FilterUtilitiesService {
}).join(','));
}
decodeSeriesFilter(encodedFilter: string) {
const filter = this.metadataService.createDefaultFilterDto();
if (encodedFilter.includes('name=')) {
filter.name = decodeURIComponent(encodedFilter).split('name=')[1].split('&')[0];
}
const stmtsStartIndex = encodedFilter.indexOf(statementsKey);
let endIndex = encodedFilter.indexOf('&' + sortOptionsKey);
if (endIndex < 0) {
endIndex = encodedFilter.indexOf('&' + limitToKey);
}
if (stmtsStartIndex !== -1 || endIndex !== -1) {
// +1 is for the =
const stmtsEncoded = encodedFilter.substring(stmtsStartIndex + statementsKey.length, endIndex);
filter.statements = this.decodeFilterStatements(stmtsEncoded);
}
if (encodedFilter.includes(sortOptionsKey)) {
const optionsStartIndex = encodedFilter.indexOf('&' + sortOptionsKey);
const endIndex = encodedFilter.indexOf('&' + limitToKey);
const sortOptionsEncoded = encodedFilter.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex);
const sortOptions = this.decodeSortOptions(sortOptionsEncoded);
if (sortOptions) {
filter.sortOptions = sortOptions;
}
}
if (encodedFilter.includes(limitToKey)) {
const limitTo = decodeURIComponent(encodedFilter).split(limitToKey)[1].split('&')[0];
filter.limitTo = parseInt(limitTo, 10);
}
if (encodedFilter.includes(combinationKey)) {
const combo = decodeURIComponent(encodedFilter).split(combinationKey)[1].split('&')[0];;
filter.combination = parseInt(combo, 10) as FilterCombination;
}
return filter;
}
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
const filter = this.metadataService.createDefaultFilterDto();
if (!window.location.href.includes('?')) return filter;

View file

@ -2,4 +2,8 @@
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
}
img {
max-width: 100%;
}

View file

@ -0,0 +1,32 @@
<ng-container *transloco="let t; read: 'customize-dashboard-modal'">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-position="idx" let-item>
<app-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-stream-list-item>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</h5>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
Add
</button>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
All Smart filters added to Dashboard or none created yet.
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</ng-container>

View file

@ -0,0 +1,24 @@
::ng-deep .drag-handle {
margin-top: 100% !important;
}
app-stream-list-item {
flex-grow: 1;
}
.filter-list {
margin: 0;
padding:0;
.filter {
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--list-group-hover-bg-color);
}
}

View file

@ -0,0 +1,72 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SafeHtmlPipe} from "../../../pipe/safe-html.pipe";
import {TranslocoDirective} from "@ngneat/transloco";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {
ReadingListItemComponent
} from "../../../reading-list/_components/reading-list-item/reading-list-item.component";
import {forkJoin} from "rxjs";
import {FilterService} from "../../../_services/filter.service";
import {StreamListItemComponent} from "../stream-list-item/stream-list-item.component";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {DashboardService} from "../../../_services/dashboard.service";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
@Component({
selector: 'app-customize-dashboard-modal',
standalone: true,
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent],
templateUrl: './customize-dashboard-modal.component.html',
styleUrls: ['./customize-dashboard-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeDashboardModalComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(public modal: NgbActiveModal) {
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
this.items = results[0];
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();
});
}
addFilterToStream(filter: SmartFilter) {
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items.push(stream);
this.cdRef.detectChanges();
});
}
orderUpdated(event: IndexUpdateEvent) {
this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
updateVisibility(item: DashboardStream, position: number) {
this.items[position].visible = !this.items[position].visible;
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
this.cdRef.markForCheck();
}
close() {
this.modal.close();
}
}

View file

@ -1,18 +1,21 @@
<ng-container *transloco="let t; read: 'side-nav'">
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">
<ng-container actions>
Todo: This will be customize dashboard/side nav controls
<a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>
</ng-container>
</app-side-nav-item> -->
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">-->
<!-- <ng-container actions>-->
<!-- <a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>-->
<!-- </ng-container>-->
<!-- </app-side-nav-item>-->
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/"></app-side-nav-item>
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
<ng-container actions>
<app-card-actionables [actions]="readingListActions" labelBy="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
<app-card-actionables [actions]="readingListActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>

View file

@ -27,11 +27,13 @@ import {FilterPipe} from "../../../pipe/filter.pipe";
import {FormsModule} from "@angular/forms";
import {TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe";
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
@Component({
selector: 'app-side-nav',
standalone: true,
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective],
imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe],
templateUrl: './side-nav.component.html',
styleUrls: ['./side-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -43,6 +45,7 @@ export class SideNavComponent implements OnInit {
libraries: Library[] = [];
actions: ActionItem<Library>[] = [];
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
filterQuery: string = '';
filterLibrary = (library: Library) => {
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
@ -107,6 +110,12 @@ export class SideNavComponent implements OnInit {
}
}
handleHomeActions() {
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
// TODO: If on /, then refresh the page layout
}
importCbl() {
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
}

View file

@ -0,0 +1,23 @@
<ng-container *transloco="let t; read: 'stream-list-item'">
<div class="row pt-2 g-0 list-item">
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.name}}
<span class="float-end">
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
</span>
</h5>
<div class="meta">
<div class="ps-1">
{{t(item.isProvided ? 'provided' : 'smart-filter')}}
</div>
<div class="ps-1" *ngIf="!item.isProvided">
<a [href]="'/all-series?' + this.item.smartFilterEncoded" target="_blank">{{t('load-filter')}}</a>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,8 @@
.list-item {
height: 60px;
max-height: 60px;
}
.meta {
display: flex;
}

View file

@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
Output
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../../shared/image/image.component";
import {MangaFormatIconPipe} from "../../../pipe/manga-format-icon.pipe";
import {MangaFormatPipe} from "../../../pipe/manga-format.pipe";
import {NgbProgressbar} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@ngneat/transloco";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
@Component({
selector: 'app-stream-list-item',
standalone: true,
imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective],
templateUrl: './stream-list-item.component.html',
styleUrls: ['./stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StreamListItemComponent {
@Input({required: true}) item!: DashboardStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
private readonly cdRef = inject(ChangeDetectorRef);
}

View file

@ -23,7 +23,7 @@ import {TopReadersComponent} from '../top-readers/top-readers.component';
import {StatListComponent} from '../stat-list/stat-list.component';
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@ -62,8 +62,6 @@ export class ServerStatsComponent {
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
}
translocoService = inject(TranslocoService);
get Breakpoint() { return Breakpoint; }
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
@ -115,7 +113,7 @@ export class ServerStatsComponent {
this.metadataService.getAllGenres().subscribe(genres => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = genres.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
ref.componentInstance.title = translate('server-stats.genres');
ref.componentInstance.clicked = (item: string) => {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
};
@ -126,7 +124,7 @@ export class ServerStatsComponent {
this.metadataService.getAllTags().subscribe(tags => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = tags.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
ref.componentInstance.title = translate('server-stats.tags');
ref.componentInstance.clicked = (item: string) => {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
};
@ -137,7 +135,7 @@ export class ServerStatsComponent {
this.metadataService.getAllPeople().subscribe(people => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
ref.componentInstance.title = this.translocoService.translate('server-stats.people');
ref.componentInstance.title = translate('server-stats.people');
});
}

View file

@ -1,6 +1,6 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import { DayOfWeek } from 'src/app/_services/statistics.service';
import {TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoService} from "@ngneat/transloco";
@Pipe({
name: 'dayOfWeek',
@ -8,24 +8,22 @@ import {TranslocoService} from "@ngneat/transloco";
})
export class DayOfWeekPipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(value: DayOfWeek): string {
switch(value) {
case DayOfWeek.Monday:
return this.translocoService.translate('day-of-week-pipe.monday');
return translate('day-of-week-pipe.monday');
case DayOfWeek.Tuesday:
return this.translocoService.translate('day-of-week-pipe.tuesday');
return translate('day-of-week-pipe.tuesday');
case DayOfWeek.Wednesday:
return this.translocoService.translate('day-of-week-pipe.wednesday');
return translate('day-of-week-pipe.wednesday');
case DayOfWeek.Thursday:
return this.translocoService.translate('day-of-week-pipe.thursday');
return translate('day-of-week-pipe.thursday');
case DayOfWeek.Friday:
return this.translocoService.translate('day-of-week-pipe.friday');
return translate('day-of-week-pipe.friday');
case DayOfWeek.Saturday:
return this.translocoService.translate('day-of-week-pipe.saturday');
return translate('day-of-week-pipe.saturday');
case DayOfWeek.Sunday:
return this.translocoService.translate('day-of-week-pipe.sunday');
return translate('day-of-week-pipe.sunday');
}
}

View file

@ -0,0 +1,11 @@
<ul>
<li class="list-group-item" *ngFor="let f of filters">
<span (click)="loadFilter(f)">{{f.name}}</span>
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">Delete</span>
</button>
</li>
<li class="list-group-item" *ngIf="filters.length === 0">No Smart Filters created</li>
</ul>

View file

@ -0,0 +1,21 @@
ul {
margin:0;
padding: 0;
li {
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--list-group-hover-bg-color);
span {
cursor: pointer;
}
}
}

View file

@ -0,0 +1,51 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FilterService} from "../../_services/filter.service";
import {SmartFilter} from "../../_models/metadata/v2/smart-filter";
import {Router} from "@angular/router";
import {ConfirmService} from "../../shared/confirm.service";
import {translate} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-manage-smart-filters',
standalone: true,
imports: [CommonModule],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageSmartFiltersComponent {
private readonly filterService = inject(FilterService);
private readonly confirmService = inject(ConfirmService);
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
filters: Array<SmartFilter> = [];
constructor() {
this.loadData();
}
loadData() {
this.filterService.getAllFilters().subscribe(filters => {
this.filters = filters;
this.cdRef.markForCheck();
});
}
async loadFilter(f: SmartFilter) {
await this.router.navigateByUrl('all-series?' + f.filter);
}
async deleteFilter(f: SmartFilter) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return;
this.filterService.deleteFilter(f.id).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-deleted'));
this.loadData();
});
}
}

View file

@ -428,6 +428,9 @@
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats></app-user-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.SmartFilters">
<app-manage-smart-filters></app-manage-smart-filters>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
<app-user-scrobble-history></app-user-scrobble-history>
<app-user-holds></app-user-holds>

View file

@ -49,6 +49,7 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
import {LocalizationService} from "../../_services/localization.service";
import {Language} from "../../_models/metadata/language";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -63,6 +64,7 @@ enum FragmentID {
Theme = 'theme',
Devices = 'devices',
Stats = 'stats',
SmartFilters = 'smart-filters',
Scrobbling = 'scrobbling'
}
@ -76,7 +78,8 @@ enum FragmentID {
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, TranslocoDirective]
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective, ManageSmartFiltersComponent]
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -107,6 +110,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
{title: 'theme-tab', fragment: FragmentID.Theme},
{title: 'devices-tab', fragment: FragmentID.Devices},
{title: 'smart-filters-tab', fragment: FragmentID.SmartFilters},
{title: 'stats-tab', fragment: FragmentID.Stats},
];
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
@ -115,7 +119,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
opdsUrl: string = '';
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
private readonly destroyRef = inject(DestroyRef);
private readonly trasnlocoService = inject(TranslocoService);
get AccordionPanelID() {
return AccordionPanelID;
@ -304,7 +307,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
};
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
this.toastr.success(this.trasnlocoService.translate('user-preferences.success-toast'));
this.toastr.success(translate('user-preferences.success-toast'));
if (this.user) {
this.user.preferences = updatedPrefs;
this.cdRef.markForCheck();

View file

@ -14,7 +14,8 @@
"not-granted": "You haven't been granted access to any libraries.",
"on-deck-title": "On Deck",
"recently-updated-title": "Recently Updated Series",
"recently-added-title": "Newly Added Series"
"recently-added-title": "Newly Added Series",
"more-in-genre-title": "More In {{genre}}"
},
"edit-user": {
@ -98,6 +99,7 @@
"devices-tab": "Devices",
"stats-tab": "Stats",
"scrobbling-tab": "Scrobbling",
"smart-filters-tab": "Smart Filters",
"success-toast": "User preferences updated",
"global-settings-title": "Global Settings",
@ -1324,6 +1326,13 @@
"read": "{{common.read}}"
},
"stream-list-item": {
"remove": "{{common.remove}}",
"load-filter": "Load Filter",
"provided": "Provided",
"smart-filter": "Smart Filter"
},
"reading-list-detail": {
"item-count": "{{common.item-count}}",
"page-settings-title": "Page Settings",
@ -1494,10 +1503,12 @@
"metadata-filter": {
"filter-title": "Filter",
"sort-by-label": "Sort By",
"filter-name-label": "Filter Name",
"ascending-alt": "Ascending",
"descending-alt": "Descending",
"reset": "{{common.reset}}",
"apply": "{{common.apply}}",
"save": "{{common.save}}",
"limit-label": "Limit To",
"format-label": "Format",
@ -1707,6 +1718,12 @@
"remove-rule": "Remove Row"
},
"customize-dashboard-modal": {
"title": "Customize Dashboard",
"close": "{{common.close}}",
"save": "{{common.save}}"
},
"filter-field-pipe": {
"age-rating": "Age Rating",
"characters": "Characters",
@ -1733,7 +1750,8 @@
"user-rating": "User Rating",
"writers": "Writers",
"path": "Path",
"file-path": "File Path"
"file-path": "File Path",
"want-to-read": "Want to Read"
},
"filter-comparison-pipe": {
@ -1755,6 +1773,8 @@
"must-contains": "Must Contains"
},
"toasts": {
"regen-cover": "A job has been enqueued to regenerate the cover image",
"no-pages": "There are no pages. Kavita was not able to read this archive.",
@ -1831,7 +1851,10 @@
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
"list-doesnt-exist": "This list doesn't exist"
"list-doesnt-exist": "This list doesn't exist",
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
"smart-filter-deleted": "Smart Filter Deleted",
"smart-filter-updated": "Created/Updated smart filter"
},
"actionable": {
@ -1861,8 +1884,8 @@
"read": "Read",
"add-rule-group-and": "Add Rule Group (AND)",
"add-rule-group-or": "Add Rule Group (OR)",
"remove-rule-group": "Remove Rule Group"
"remove-rule-group": "Remove Rule Group",
"customize": "Customize"
},
"preferences": {