Kavita+ Overhaul & New Changelog (#3507)

This commit is contained in:
Joe Milazzo 2025-01-20 08:14:57 -06:00 committed by GitHub
parent d880c1690c
commit a5707617f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
249 changed files with 14775 additions and 2300 deletions

View file

@ -0,0 +1,7 @@
export interface EmailHistory {
sent: boolean;
sendDate: string;
emailTemplate: string;
errorMessage: string;
toUserName: string;
}

View file

@ -1,12 +1,24 @@
export interface UpdateVersionEvent {
currentVersion: string;
updateVersion: string;
updateBody: string;
updateTitle: string;
updateUrl: string;
isDocker: boolean;
publishDate: string;
isOnNightlyInRelease: boolean;
isReleaseNewer: boolean;
isReleaseEqual: boolean;
currentVersion: string;
updateVersion: string;
updateBody: string;
updateTitle: string;
updateUrl: string;
isDocker: boolean;
publishDate: string;
isOnNightlyInRelease: boolean;
isReleaseNewer: boolean;
isReleaseEqual: boolean;
added: Array<string>;
removed: Array<string>;
changed: Array<string>;
fixed: Array<string>;
theme: Array<string>;
developer: Array<string>;
api: Array<string>;
/**
* The part above the changelog part
*/
blogPart: string;
}

View file

@ -0,0 +1,9 @@
export interface LicenseInfo {
expirationDate: string;
isActive: boolean;
isCancelled: boolean;
isValidVersion: boolean;
registeredEmail: string;
totalMonthsSubbed: number;
hasLicense: boolean;
}

View file

@ -0,0 +1,6 @@
import {MatchStateOption} from "./match-state-option";
export interface ManageMatchFilter {
matchStateOption: MatchStateOption;
searchTerm: string;
}

View file

@ -0,0 +1,7 @@
import {Series} from "../series";
export interface ManageMatchSeries {
series: Series;
isMatched: boolean;
validUntilUtc: string;
}

View file

@ -0,0 +1,11 @@
export enum MatchStateOption {
All = 0,
Matched = 1,
NotMatched = 2,
Error = 3,
DontMatch = 4
}
export const allMatchStates = [
MatchStateOption.Matched, MatchStateOption.NotMatched, MatchStateOption.Error, MatchStateOption.DontMatch
];

View file

@ -0,0 +1,8 @@
export interface UserTokenInfo {
userId: number;
username: string;
isAniListTokenSet: boolean;
aniListValidUntilUtc: string;
isAniListTokenValid: boolean;
isMalTokenSet: boolean;
}

View file

@ -0,0 +1,6 @@
import {ExternalSeriesDetail} from "./external-series-detail";
export interface ExternalSeriesMatch {
series: ExternalSeriesDetail;
matchRating: number;
}

View file

@ -5,70 +5,78 @@ import {IHasReadingTime} from "./common/i-has-reading-time";
import {IHasProgress} from "./common/i-has-progress";
export interface Series extends IHasCover, IHasReadingTime, IHasProgress {
id: number;
name: string;
/**
* This is not shown to user
*/
originalName: string;
localizedName: string;
sortName: string;
coverImageLocked: boolean;
sortNameLocked: boolean;
localizedNameLocked: boolean;
nameLocked: boolean;
volumes: Volume[];
/**
* Total pages in series
*/
pages: number;
/**
* Total pages the logged in user has read
*/
pagesRead: number;
/**
* User's rating (0-5)
*/
userRating: number;
hasUserRated: boolean;
libraryId: number;
/**
* DateTime the entity was created
*/
created: string;
/**
* Format of the Series
*/
format: MangaFormat;
/**
* DateTime that represents last time the logged in user read this series
*/
latestReadDate: string;
/**
* DateTime representing last time a chapter was added to the Series
*/
lastChapterAdded: string;
/**
* DateTime representing last time the series folder was scanned
*/
lastFolderScanned: string;
/**
* Number of words in the series
*/
wordCount: number;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
/**
* Highest level folder containing this series
*/
folderPath: string;
lowestFolderPath: string;
/**
* This is currently only used on Series detail page for recommendations
*/
summary?: string;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
id: number;
name: string;
/**
* This is not shown to user
*/
originalName: string;
localizedName: string;
sortName: string;
coverImageLocked: boolean;
sortNameLocked: boolean;
localizedNameLocked: boolean;
nameLocked: boolean;
volumes: Volume[];
/**
* Total pages in series
*/
pages: number;
/**
* Total pages the logged in user has read
*/
pagesRead: number;
/**
* User's rating (0-5)
*/
userRating: number;
hasUserRated: boolean;
libraryId: number;
/**
* DateTime the entity was created
*/
created: string;
/**
* Format of the Series
*/
format: MangaFormat;
/**
* DateTime that represents last time the logged in user read this series
*/
latestReadDate: string;
/**
* DateTime representing last time a chapter was added to the Series
*/
lastChapterAdded: string;
/**
* DateTime representing last time the series folder was scanned
*/
lastFolderScanned: string;
/**
* Number of words in the series
*/
wordCount: number;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
/**
* Highest level folder containing this series
*/
folderPath: string;
lowestFolderPath: string;
/**
* This is currently only used on Series detail page for recommendations
*/
summary?: string;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
/**
* Kavita+ only. Will not perform any matching from Kavita+
*/
dontMatch: boolean;
/**
* Kavita+ only. Did this series not match and won't without manual match
*/
isBlacklisted: boolean;
}

View file

@ -8,7 +8,7 @@ export enum WikiLink {
DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me',
MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/media#media-issues',
KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id',
KavitaPlus = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+',
KavitaPlus = 'https://wiki.kavitareader.com/kavita+/features/',
KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq',
ReadingListCBL = 'https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl',
Donation = 'https://wiki.kavitareader.com/donating',

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import { translate } from '@jsverse/transloco';
@Pipe({
name: 'confirmTranslate',
standalone: true
})
export class ConfirmTranslatePipe implements PipeTransform {
transform(value: string | undefined | null): string | undefined | null {
if (!value) return value;
if (value.startsWith('confirm.')) {
return translate(value);
}
return value;
}
}

View file

@ -0,0 +1,27 @@
import { Pipe, PipeTransform } from '@angular/core';
import {MatchStateOption} from "../_models/kavitaplus/match-state-option";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'matchStateOption',
standalone: true
})
export class MatchStateOptionPipe implements PipeTransform {
transform(value: MatchStateOption): string {
switch (value) {
case MatchStateOption.DontMatch:
return translate('manage-matched-metadata.dont-match-label');
case MatchStateOption.All:
return translate('manage-matched-metadata.all-status-label');
case MatchStateOption.Matched:
return translate('manage-matched-metadata.matched-status-label');
case MatchStateOption.NotMatched:
return translate('manage-matched-metadata.unmatched-status-label');
case MatchStateOption.Error:
return translate('manage-matched-metadata.blacklist-status-label');
}
}
}

View file

@ -0,0 +1,25 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PlusMediaFormat} from "../_models/series-detail/external-series-detail";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'plusMediaFormat',
standalone: true
})
export class PlusMediaFormatPipe implements PipeTransform {
transform(value: PlusMediaFormat): string {
switch (value) {
case PlusMediaFormat.Manga:
return translate('library-type-pipe.manga');
case PlusMediaFormat.Comic:
return translate('library-type-pipe.comic');
case PlusMediaFormat.LightNovel:
return translate('library-type-pipe.lightNovel');
case PlusMediaFormat.Book:
return translate('library-type-pipe.book');
}
}
}

View file

@ -16,6 +16,8 @@ import { TextResonse } from '../_types/text-response';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Action} from "./action-factory.service";
import {CoverImageSize} from "../admin/_models/cover-image-size";
import {LicenseInfo} from "../_models/kavitaplus/license-info";
import {LicenseService} from "./license.service";
export enum Role {
Admin = 'Admin',
@ -45,6 +47,7 @@ export const allRoles = [
export class AccountService {
private readonly destroyRef = inject(DestroyRef);
private readonly licenseService = inject(LicenseService);
baseUrl = environment.apiUrl;
userKey = 'kavita-user';
@ -54,17 +57,13 @@ export class AccountService {
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User | undefined>(1);
public currentUser$ = this.currentUserSource.asObservable();
public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
public isAdmin$: Observable<boolean> = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
if (!u) return false;
return this.hasAdminRole(u);
}), shareReplay({bufferSize: 1, refCount: true}));
private hasValidLicenseSource = new ReplaySubject<boolean>(1);
/**
* Does the user have an active license
*/
public hasValidLicense$ = this.hasValidLicenseSource.asObservable();
/**
* SetTimeout handler for keeping track of refresh token call
@ -154,40 +153,7 @@ export class AccountService {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
}
deleteLicense() {
return this.httpClient.delete<string>(this.baseUrl + 'license', TextResonse);
}
resetLicense(license: string, email: string) {
return this.httpClient.post<string>(this.baseUrl + 'license/reset', {license, email}, TextResonse);
}
hasValidLicense(forceCheck: boolean = false) {
console.log('hasValidLicense being called: ', forceCheck);
return this.httpClient.get<string>(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse)
.pipe(
map(res => res === "true"),
tap(res => {
this.hasValidLicenseSource.next(res)
}),
catchError(error => {
this.hasValidLicenseSource.next(false);
return throwError(error); // Rethrow the error to propagate it further
})
);
}
hasAnyLicense() {
return this.httpClient.get<string>(this.baseUrl + 'license/has-license', TextResonse)
.pipe(
map(res => res === "true"),
);
}
updateUserLicense(license: string, email: string, discordId?: string) {
return this.httpClient.post<string>(this.baseUrl + 'license', {license, email, discordId}, TextResonse)
.pipe(map(res => res === "true"));
}
login(model: {username: string, password: string, apiKey?: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
@ -231,7 +197,7 @@ export class AccountService {
// But that really messes everything up
this.messageHub.stopHubConnection();
this.messageHub.createHubConnection(this.currentUser);
this.hasValidLicense().subscribe();
this.licenseService.hasValidLicense().subscribe();
this.startRefreshTokenTimer();
}
}

View file

@ -112,7 +112,11 @@ export enum Action {
/**
* Copy settings from one entity to another
*/
CopySettings = 27
CopySettings = 27,
/**
* Match an entity with an upstream system
*/
Match = 28
}
/**
@ -463,6 +467,7 @@ export class ActionFactoryService {
requiresAdmin: false,
children: [],
},
// {
// action: Action.AddToScrobbleHold,
// title: 'add-to-scrobble-hold',
@ -543,6 +548,14 @@ export class ActionFactoryService {
},
],
},
{
action: Action.Match,
title: 'match',
description: 'match-tooltip',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.Download,
title: 'download',

View file

@ -26,6 +26,8 @@ import {ReadingListService} from "./reading-list.service";
import {ChapterService} from "./chapter.service";
import {VolumeService} from "./volume.service";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component";
export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
@ -770,6 +772,16 @@ export class ActionService {
});
}
matchSeries(series: Series, callback?: BooleanActionCallback) {
const ref = this.modalService.open(MatchSeriesModalComponent, {size: 'lg'});
ref.componentInstance.series = series;
ref.closed.subscribe(saved => {
if (callback) {
callback(saved);
}
});
}
async deleteFilter(filterId: number, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) {
if (callback) {

View file

@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {EmailHistory} from "../_models/email-history";
@Injectable({
providedIn: 'root'
})
export class EmailService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getEmailHistory() {
return this.httpClient.get<EmailHistory[]>(`${this.baseUrl}email/all`);
}
}

View file

@ -0,0 +1,86 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {catchError, map, of, ReplaySubject, tap, throwError} from "rxjs";
import {environment} from "../../environments/environment";
import { TextResonse } from '../_types/text-response';
import {LicenseInfo} from "../_models/kavitaplus/license-info";
import {translate} from "@jsverse/transloco";
import {ConfirmService} from "../shared/confirm.service";
@Injectable({
providedIn: 'root'
})
export class LicenseService {
private readonly httpClient = inject(HttpClient);
baseUrl = environment.apiUrl;
private readonly hasValidLicenseSource = new ReplaySubject<boolean>(1);
/**
* Does the user have an active license
*/
public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable();
/**
* Delete the license from the server and update hasValidLicenseSource to false
*/
deleteLicense() {
return this.httpClient.delete<string>(this.baseUrl + 'license', TextResonse).pipe(
map(res => res === "true"),
tap(_ => {
this.hasValidLicenseSource.next(false)
}),
catchError(error => {
this.hasValidLicenseSource.next(false);
return throwError(error); // Rethrow the error to propagate it further
})
);
}
resetLicense(license: string, email: string) {
return this.httpClient.post<string>(this.baseUrl + 'license/reset', {license, email}, TextResonse);
}
/**
* Returns information about License and will internally cache if license is valid or not
*/
licenseInfo(forceCheck: boolean = false) {
return this.httpClient.get<LicenseInfo | null>(this.baseUrl + `license/info?forceCheck=${forceCheck}`).pipe(
tap(res => {
this.hasValidLicenseSource.next(res?.isActive || false)
}),
catchError(error => {
this.hasValidLicenseSource.next(false);
return throwError(error); // Rethrow the error to propagate it further
})
);
}
hasValidLicense(forceCheck: boolean = false) {
console.log('hasValidLicense being called: ', forceCheck);
return this.httpClient.get<string>(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse)
.pipe(
map(res => res === "true"),
tap(res => {
this.hasValidLicenseSource.next(res)
}),
catchError(error => {
this.hasValidLicenseSource.next(false);
return throwError(error); // Rethrow the error to propagate it further
})
);
}
hasAnyLicense() {
return this.httpClient.get<string>(this.baseUrl + 'license/has-license', TextResonse)
.pipe(
map(res => res === "true"),
);
}
updateUserLicense(license: string, email: string, discordId?: string) {
return this.httpClient.post<string>(this.baseUrl + 'license', {license, email, discordId}, TextResonse)
.pipe(map(res => res === "true"));
}
}

View file

@ -0,0 +1,18 @@
import {inject, Injectable} from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {ManageMatchSeries} from "../_models/kavitaplus/manage-match-series";
import {ManageMatchFilter} from "../_models/kavitaplus/manage-match-filter";
@Injectable({
providedIn: 'root'
})
export class ManageService {
baseUrl = environment.apiUrl;
private readonly httpClient = inject(HttpClient);
getAllKavitaPlusSeries(filter: ManageMatchFilter) {
return this.httpClient.post<Array<ManageMatchSeries>>(this.baseUrl + `manage/series-metadata`, filter);
}
}

View file

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { Member } from '../_models/auth/member';
import {UserTokenInfo} from "../_models/kavitaplus/user-token-info";
@Injectable({
providedIn: 'root'
@ -20,6 +21,10 @@ export class MemberService {
return this.httpClient.get<string[]>(this.baseUrl + 'users/names');
}
getUserTokenInfo() {
return this.httpClient.get<UserTokenInfo[]>(this.baseUrl + 'users/tokens');
}
adminExists() {
return this.httpClient.get<boolean>(this.baseUrl + 'admin/exists');
}
@ -37,11 +42,11 @@ export class MemberService {
}
addSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/add-series', {seriesIds});
return this.httpClient.post(this.baseUrl + 'want-to-read/add-series', {seriesIds});
}
removeSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
return this.httpClient.post(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
}
getMember() {

View file

@ -32,12 +32,20 @@ export class ScrobblingService {
.pipe(map(r => r === "true"));
}
/**
* Returns if the token was new or not
*/
updateAniListToken(token: string) {
return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token});
return this.httpClient.post<boolean>(this.baseUrl + 'scrobbling/update-anilist-token', {token}, TextResonse)
.pipe(map(r => r + '' === 'true'));
}
/**
* Returns if the token was new or not
*/
updateMalToken(username: string, accessToken: string) {
return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken});
return this.httpClient.post<boolean>(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}, TextResonse)
.pipe(map(r => r + '' === 'true'));
}
getAniListToken() {
@ -87,4 +95,9 @@ export class ScrobblingService {
removeHold(seriesId: number) {
return this.httpClient.delete(this.baseUrl + 'scrobbling/remove-hold?seriesId=' + seriesId, TextResonse);
}
triggerScrobbleEventGeneration() {
return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse);
}
}

View file

@ -21,6 +21,8 @@ import {Recommendation} from "../_models/series-detail/recommendation";
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
import {QueryContext} from "../_models/metadata/v2/query-context";
import {ExternalSeries} from "../_models/series-detail/external-series";
import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match";
@Injectable({
providedIn: 'root'
@ -235,4 +237,16 @@ export class SeriesService {
return this.httpClient.get<NextExpectedChapter>(this.baseUrl + 'series/next-expected?seriesId=' + seriesId);
}
matchSeries(model: any) {
return this.httpClient.post<Array<ExternalSeriesMatch>>(this.baseUrl + 'series/match', model);
}
updateMatch(seriesId: number, series: ExternalSeriesDetail) {
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId, series, TextResonse);
}
updateDontMatch(seriesId: number, dontMatch: boolean) {
return this.httpClient.post<string>(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse);
}
}

View file

@ -62,8 +62,8 @@ export class ServerService {
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-for-updates', {});
}
getChangelog() {
return this.http.get<UpdateVersionEvent[]>(this.baseUrl + 'server/changelog', {});
getChangelog(count: number = 0) {
return this.http.get<UpdateVersionEvent[]>(this.baseUrl + 'server/changelog?count=' + count, {});
}
getRecurringJobs() {

View file

@ -134,8 +134,4 @@ export class StatisticsService {
getDayBreakdown( userId = 0) {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
}
getKavitaPlusMetadataBreakdown() {
return this.httpClient.get<KavitaPlusMetadataBreakdown>(this.baseUrl + 'stats/kavitaplus-metadata-breakdown');
}
}

View file

@ -0,0 +1,97 @@
import {inject, Injectable, OnDestroy} from '@angular/core';
import {interval, Subscription, switchMap} from 'rxjs';
import {ServerService} from "./server.service";
import {AccountService} from "./account.service";
import {filter, tap} from "rxjs/operators";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component";
@Injectable({
providedIn: 'root'
})
export class VersionService implements OnDestroy{
private readonly serverService = inject(ServerService);
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
public static readonly versionKey = 'kavita--version';
private readonly checkInterval = 600000; // 10 minutes (600000)
private periodicCheckSubscription?: Subscription;
private outOfDateCheckSubscription?: Subscription;
private modalOpen = false;
constructor() {
this.startPeriodicUpdateCheck();
this.startOutOfDateCheck();
}
ngOnDestroy() {
this.periodicCheckSubscription?.unsubscribe();
this.outOfDateCheckSubscription?.unsubscribe();
}
private startOutOfDateCheck() {
// Every hour, have the UI check for an update. People seriously stay out of date
this.outOfDateCheckSubscription = interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionOutOfDate => {
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
}),
tap(versionOutOfDate => {
if (!this.modalService.hasOpenModals()) {
const ref = this.modalService.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
ref.componentInstance.versionsOutOfDate = versionOutOfDate;
}
})
)
.subscribe();
}
private startPeriodicUpdateCheck(): void {
console.log('Starting periodic version update checker');
this.periodicCheckSubscription = interval(this.checkInterval)
.pipe(
switchMap(_ => this.accountService.currentUser$),
filter(user => user !== undefined && !this.modalOpen),
switchMap(user => this.serverService.getVersion(user!.apiKey)),
).subscribe(version => this.handleVersionUpdate(version));
}
private handleVersionUpdate(version: string) {
if (this.modalOpen) return;
// Pause periodic checks while the modal is open
this.periodicCheckSubscription?.unsubscribe();
const cachedVersion = localStorage.getItem(VersionService.versionKey);
console.log('Kavita version: ', version, ' Running version: ', cachedVersion);
const hasChanged = cachedVersion == null || cachedVersion != version;
if (hasChanged) {
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg'});
ref.componentInstance.version = version;
ref.componentInstance.update = changelog[0];
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
});
}
localStorage.setItem(VersionService.versionKey, version);
}
private onModalClosed() {
this.modalOpen = false;
this.startPeriodicUpdateCheck();
}
}

View file

@ -0,0 +1,69 @@
<ng-container *transloco="let t; read:'match-series-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{t('title', {seriesName: series.name})}}
</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="formGroup">
<p>{{t('description')}}</p>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-item [title]="t('query-label')" [subtitle]="t('query-tooltip')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<div class="input-group">
@if (formGroup.get('query'); as formControl) {
<input id="query" class="form-control" formControlName="query" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (formControl.errors) {
@if (formControl.errors.required) {
<div class="invalid-feedback">{{t('required-field')}}</div>
}
}
}
</div>
</ng-template>
</app-setting-item>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch">
</div>
</ng-template>
</app-setting-switch>
</div>
</div>
</div>
<div class="row g-0">
<button class="btn btn-primary" [disabled]="formGroup.get('dontMatch')?.value" (click)="search()">{{t('search')}}</button>
</div>
</form>
<div class="setting-section-break"></div>
@if (!formGroup.get('dontMatch')?.value) {
<app-loading [loading]="isLoading"></app-loading>
@for(item of matches; track item.series.name) {
<app-match-series-result-item [item]="item" (selected)="selectMatch($event)"></app-match-series-result-item>
<div class="setting-section-break"></div>
} @empty {
@if (!isLoading) {
{{t('no-results')}}
}
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,94 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {Series} from "../../_models/series";
import {SeriesService} from "../../_services/series.service";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {MatchSeriesResultItemComponent} from "../match-series-result-item/match-series-result-item.component";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {ToastrService} from "ngx-toastr";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
@Component({
selector: 'app-match-series-modal',
standalone: true,
imports: [
TranslocoDirective,
MatchSeriesResultItemComponent,
LoadingComponent,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent
],
templateUrl: './match-series-modal.component.html',
styleUrl: './match-series-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MatchSeriesModalComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly seriesService = inject(SeriesService);
private readonly modalService = inject(NgbActiveModal);
private readonly toastr = inject(ToastrService);
@Input({required: true}) series!: Series;
formGroup = new FormGroup({});
matches: Array<ExternalSeriesMatch> = [];
isLoading = true;
ngOnInit() {
this.formGroup.addControl('query', new FormControl('', []));
this.formGroup.addControl('dontMatch', new FormControl(this.series?.dontMatch || false, []));
this.search();
}
search() {
this.isLoading = true;
this.cdRef.markForCheck();
const model: any = this.formGroup.value;
model.seriesId = this.series.id;
if (model.dontMatch) return;
this.seriesService.matchSeries(model).subscribe(results => {
this.isLoading = false;
this.matches = results;
this.cdRef.markForCheck();
});
}
close() {
this.modalService.close(false);
}
save() {
const model: any = this.formGroup.value;
model.seriesId = this.series.id;
// We need to update the dontMatch status
if (model.dontMatch) {
this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => {
this.modalService.close(true);
});
} else {
this.toastr.success(translate('toasts.match-success'));
this.modalService.close(true);
}
}
selectMatch(item: ExternalSeriesMatch) {
const data = item.series;
data.tags = data.tags || [];
data.genres = data.genres || [];
this.seriesService.updateMatch(this.series.id, data).subscribe(_ => {
this.save();
});
}
}

View file

@ -0,0 +1,38 @@
<ng-container *transloco="let t; read:'match-series-result-item'">
<div class="d-flex p-1 clickable" (click)="selectItem()">
<div style="width: 32px" class="me-1">
@if (item.series.coverUrl) {
<app-image class="me-3 search-result" width="32px" [imageUrl]="item.series.coverUrl"></app-image>
}
</div>
<div class="ms-1">
<div>{{item.series.name}}</div>
<div class="text-muted">
@for(synm of item.series.synonyms; track synm; let last = $last) {
{{synm}}
@if (!last) {
<span>, </span>
}
}
</div>
@if (item.series.summary) {
<div>
<app-read-more [text]="item.series.summary" [showToggle]="false"></app-read-more>
</div>
}
</div>
</div>
<div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
</div>
</ng-container>

View file

@ -0,0 +1,44 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
Output
} from '@angular/core';
import {ImageComponent} from "../../shared/image/image.component";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {PercentPipe} from "@angular/common";
import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
@Component({
selector: 'app-match-series-result-item',
standalone: true,
imports: [
ImageComponent,
TranslocoPercentPipe,
ReadMoreComponent,
TranslocoDirective,
PlusMediaFormatPipe
],
templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MatchSeriesResultItemComponent {
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) item!: ExternalSeriesMatch;
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
selectItem() {
this.selected.emit(this.item);
}
}

View file

@ -1,103 +1,118 @@
<ng-container *transloco="let t; read:'user-scrobble-history'">
<h5>{{t('title')}}</h5>
<div class="position-relative">
<button class="btn btn-primary-outline position-absolute custom-position" [disabled]="events.length > 0" (click)="generateScrobbleEvents()" [title]="t('generate-scrobble-events')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('generate-scrobble-events')}}</span>
</button>
</div>
@if (tokenExpired) {
<p class="alert alert-warning">{{t('token-expired')}}</p>
}
<p>{{t('description')}}</p>
<p class="fw-bold">{{t('not-read-warning')}}</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
</div>
<div class="col-md-2 mt-4">
@if(pagination) {
<ngb-pagination [(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
}
</div>
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
</div>
<table class="table table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="lastModifiedUtc" (sort)="updateSort($event)" direction="desc">
{{t('last-modified-header')}}
</th>
<th scope="col">
{{t('type-header')}}
</th>
<th scope="col" sortable="seriesName" (sort)="updateSort($event)">
{{t('series-header')}}
</th>
<th scope="col">
{{t('data-header')}}
</th>
<th scope="col">
{{t('is-processed-header')}}
</th>
</tr>
</thead>
<tbody>
@for(item of events; track item; let idx = $index) {
<tr>
<td>
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</td>
<td>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
} @empty {
<tr><td colspan="6" style="text-align: center;">{{t('no-data')}}</td></tr>
}
</tbody>
</table>
<ngx-datatable
class="bootstrap"
[rows]="events"
[columnMode]="ColumnMode.force"
(sort)="updateSort($event)"
(page)="onPageChange($event)"
rowHeight="auto"
[footerHeight]="50"
[externalPaging]="true"
[count]="pageInfo.totalElements"
[offset]="pageInfo.pageNumber"
[limit]="pageInfo.size"
>
<ngx-datatable-column name="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-modified-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('type-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | scrobbleEventType}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank" id="scrobble-history--{{idx}}">{{item.seriesName}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('data-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ng-container>

View file

@ -5,3 +5,8 @@
.error {
color: var(--error-color);
}
.custom-position {
right: 15px;
top: -42px;
}

View file

@ -4,11 +4,11 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe";
import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
import {debounceTime, take} from "rxjs/operators";
import {PaginatedResult, Pagination} from "../../_models/pagination";
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
import {SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
@ -16,37 +16,57 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ToastrService} from "ngx-toastr";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {CardActionablesComponent} from "../card-actionables/card-actionables.component";
export interface DataTablePage {
pageNumber: number,
size: number,
totalElements: number,
totalPages: number
}
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip],
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserScrobbleHistoryComponent implements OnInit {
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ColumnMode = ColumnMode;
private readonly scrobblingService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
protected readonly ScrobbleEventType = ScrobbleEventType;
pagination: Pagination | undefined;
events: Array<ScrobbleEvent> = [];
tokenExpired = false;
formGroup: FormGroup = new FormGroup({
'filter': new FormControl('', [])
});
events: Array<ScrobbleEvent> = [];
isLoading: boolean = true;
pageInfo: DataTablePage = {
pageNumber: 0,
size: 10,
totalElements: 0,
totalPages: 0
}
ngOnInit() {
this.loadPage({column: 'createdUtc', direction: 'desc'});
this.onPageChange({offset: 0});
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
if (hasExpired) {
this.toastr.error(translate('toasts.anilist-token-expired'));
}
this.tokenExpired = hasExpired;
this.cdRef.markForCheck();
});
@ -55,38 +75,41 @@ export class UserScrobbleHistoryComponent implements OnInit {
})
}
onPageChange(pageNum: number) {
let prevPage = 0;
if (this.pagination) {
prevPage = this.pagination.currentPage;
this.pagination.currentPage = pageNum;
}
if (prevPage !== pageNum) {
this.loadPage();
}
onPageChange(pageInfo: any) {
this.pageInfo.pageNumber = pageInfo.offset;
this.cdRef.markForCheck();
this.loadPage();
}
updateSort(sortEvent: SortEvent<ScrobbleEvent>) {
this.loadPage(sortEvent);
updateSort(data: any) {
this.loadPage({column: data.column.prop, direction: data.newValue});
}
loadPage(sortEvent?: SortEvent<ScrobbleEvent>) {
if (sortEvent && this.pagination) {
this.pagination.currentPage = 1;
if (sortEvent && this.pageInfo) {
this.pageInfo.pageNumber = 1;
this.cdRef.markForCheck();
}
const page = this.pagination?.currentPage || 0;
const pageSize = this.pagination?.itemsPerPage || 0;
const page = (this.pageInfo?.pageNumber || 0) + 1;
const pageSize = this.pageInfo?.size || 0;
const isDescending = sortEvent?.direction === 'desc';
const field = this.mapSortColumnField(sortEvent?.column);
const query = this.formGroup.get('filter')?.value;
this.isLoading = true;
this.cdRef.markForCheck();
this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
.pipe(take(1))
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
this.events = result.result;
this.pagination = result.pagination;
this.pageInfo.totalPages = result.pagination.totalPages - 1; // ngx-datatable is 0 based, Kavita is 1 based
this.pageInfo.size = result.pagination.itemsPerPage;
this.pageInfo.totalElements = result.pagination.totalItems;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
@ -101,7 +124,9 @@ export class UserScrobbleHistoryComponent implements OnInit {
return ScrobbleEventSortField.None;
}
generateScrobbleEvents() {
this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => {
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
});
}
}

View file

@ -0,0 +1,57 @@
<ng-container *transloco="let t; read:'email-history'">
<p>{{t('description')}}</p>
<ngx-datatable
class="bootstrap"
[rows]="data"
[columnMode]="ColumnMode.force"
rowHeight="auto"
[footerHeight]="50"
>
<ngx-datatable-column name="emailTemplate" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('template-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.emailTemplate}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="sendDate" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('date-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.sendDate | utcToLocalTime}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="toUserName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('user-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.toUserName}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="sent" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('sent-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@if (item.sent) {
<i class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('sent-tooltip')}}</span>
</i>
} @else {
<i class="error fa-solid fa-exclamation-circle ms-1">
<span class="visually-hidden">{{t('not-sent-tooltip')}}</span>
</i>
}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ng-container>

View file

@ -0,0 +1,42 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {EmailHistory} from "../../_models/email-history";
import {EmailService} from "../../_services/email.service";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@Component({
selector: 'app-email-history',
standalone: true,
imports: [
TranslocoDirective,
VirtualScrollerModule,
UtcToLocalTimePipe,
LoadingComponent,
DefaultValuePipe,
NgxDatatableModule
],
templateUrl: './email-history.component.html',
styleUrl: './email-history.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EmailHistoryComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly emailService = inject(EmailService)
isLoading = true;
data: Array<EmailHistory> = [];
ngOnInit() {
this.emailService.getEmailHistory().subscribe(data => {
this.data = data;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
protected readonly ColumnMode = ColumnMode;
}

View file

@ -1,15 +1,21 @@
<ng-container *transloco="let t; read: 'license'">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
<div class="position-relative">
<a class="position-absolute custom-position btn btn-primary-outline" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a>
</div>
<div class="container-fluid">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
</div>
<form [formGroup]="formGroup">
<div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
<ng-template #titleExtra>
<button class="btn btn-icon btn-sm" style="width: 58px" (click)="validateLicense()">
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
} @else {
} @else if (hasLicense) {
<span>
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
</span>
@ -25,7 +31,7 @@
<span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
@if (hasValidLicense) {
@if (licenseInfo?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
@ -35,6 +41,10 @@
</i>
}
}
@if (!isChecking && hasLicense && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
} @else {
{{t('no-license-key')}}
}
@ -54,7 +64,7 @@
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
@if (formGroup.dirty || formGroup.touched) {
@if (formGroup.dirty || !formGroup.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formGroup.get('discordId')?.errors?.pattern) {
<div>
@ -66,10 +76,10 @@
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
(click)="deleteLicense()">
{{t('activate-delete')}}
</button>
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
<!-- (click)="deleteLicense()">-->
<!-- {{t('activate-delete')}}-->
<!-- </button>-->
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
[ngbTooltip]="t('activate-reset-tooltip')"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
@ -89,7 +99,7 @@
<ng-template #titleActions>
@if (hasLicense) {
@if (hasValidLicense) {
@if (licenseInfo?.isActive) {
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else {
<a class="btn btn-primary-outline btn-sm me-1"
@ -106,4 +116,85 @@
</div>
</form>
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-3">
<h3 class="container-fluid">{{t('info-title')}}</h3>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view>
@if (isChecking) {
{{null | defaultValue}}
} @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
</i>
}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i>
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}}
</ng-template>
</app-setting-item>
</div>
<div class="col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view>
<span (click)="toggleEmailShow()" class="col-12 clickable">
@if (showEmail) {
{{licenseInfo.registeredEmail}}
} @else {
***************
}
</span>
</ng-template>
</app-setting-item>
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->
<h3 class="container-fluid">{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
</div>
}
</ng-container>

View file

@ -5,3 +5,13 @@
.successful-validation {
color: var(--primary-color);
}
.custom-position {
right: 15px;
top: -42px;
}
.custom-position-2 {
right: 160px;
top: -42px;
}

View file

@ -1,7 +1,7 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, inject,
Component, DestroyRef, inject,
OnInit
} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
@ -13,8 +13,15 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {environment} from "../../../environments/environment";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki";
import {RouterLink} from "@angular/router";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {DecimalPipe} from "@angular/common";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {switchMap} from "rxjs";
import {LicenseInfo} from "../../_models/kavitaplus/license-info";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {filter, tap} from "rxjs/operators";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {LicenseService} from "../../_services/license.service";
@Component({
selector: 'app-license',
@ -22,50 +29,66 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
styleUrls: ['./license.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbTooltip, LoadingComponent, ReactiveFormsModule, TranslocoDirective, RouterLink, SettingItemComponent]
imports: [NgbTooltip, LoadingComponent, ReactiveFormsModule, TranslocoDirective, SettingItemComponent,
DefaultValuePipe, UtcToLocalTimePipe, SettingButtonComponent, DecimalPipe]
})
export class LicenseComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
protected readonly WikiLink = WikiLink;
formGroup: FormGroup = new FormGroup({});
isViewMode: boolean = true;
hasValidLicense: boolean = false;
hasLicense: boolean = false;
isChecking: boolean = true;
isSaving: boolean = false;
hasLicense: boolean = false;
licenseInfo: LicenseInfo | null = null;
showEmail: boolean = false;
buyLink = environment.buyLink;
manageLink = environment.manageLink;
ngOnInit(): void {
this.formGroup.addControl('licenseKey', new FormControl('', [Validators.required]));
this.formGroup.addControl('email', new FormControl('', [Validators.required]));
this.formGroup.addControl('discordId', new FormControl('', [Validators.pattern(/\d+/)]));
this.loadLicenseInfo().subscribe();
}
loadLicenseInfo(forceCheck = false) {
this.isChecking = true;
this.cdRef.markForCheck();
this.accountService.hasAnyLicense().subscribe(res => {
this.hasLicense = res;
this.cdRef.markForCheck();
if (this.hasLicense) {
this.accountService.hasValidLicense().subscribe(res => {
this.hasValidLicense = res;
return this.licenseService.hasAnyLicense()
.pipe(
tap(res => {
this.hasLicense = res;
this.isChecking = false;
this.cdRef.markForCheck();
});
}
});
}),
filter(hasLicense => hasLicense),
tap(_ => {
this.isChecking = true;
this.cdRef.markForCheck();
}),
switchMap(_ => this.licenseService.licenseInfo(forceCheck)),
tap(licenseInfo => {
this.licenseInfo = licenseInfo;
this.isChecking = false;
this.cdRef.markForCheck();
})
);
}
@ -79,30 +102,75 @@ export class LicenseComponent implements OnInit {
saveForm() {
this.isSaving = true;
this.cdRef.markForCheck();
this.accountService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim(), this.formGroup.get('discordId')!.value.trim())
const hadActiveLicenseBefore = this.licenseInfo?.isActive;
this.licenseService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim(), this.formGroup.get('discordId')!.value.trim())
.subscribe(() => {
this.accountService.hasValidLicense(true).subscribe(isValid => {
this.hasValidLicense = isValid;
if (!this.hasValidLicense) {
this.toastr.info(translate('toasts.k+-license-saved'));
} else {
this.toastr.success(translate('toasts.k+-unlocked'));
}
this.hasLicense = this.formGroup.get('licenseKey')!.value.length > 0;
this.resetForm();
this.isViewMode = true;
this.isSaving = false;
this.cdRef.markForCheck();
this.loadLicenseInfo().subscribe(async (info) => {
if (info?.isActive && !hadActiveLicenseBefore) {
await this.confirmService.info(translate('license.k+-unlocked-description'), translate('license.k+-unlocked'));
} else {
this.toastr.info(translate('toasts.k+-license-saved'));
}
});
}, async (err) => {
await this.handleError(err);
});
}, err => {
this.isSaving = false;
this.cdRef.markForCheck();
if (err.hasOwnProperty('error')) {
this.toastr.error(JSON.parse(err['error']));
}
private async handleError(err: any) {
this.isSaving = false;
this.cdRef.markForCheck();
if (err.hasOwnProperty('error')) {
if (err['error'][0] === '{') {
this.toastr.error(JSON.parse(err['error']));
} else {
// Prompt user if they want to override their instance. This will call the rest flow then the register flow
if (err['error'] === 'Kavita instance already registered with another license') {
const answer = await this.confirmService.confirm(translate('license.k+-license-overwrite'), {
_type: 'confirm',
content: translate('license.k+-license-overwrite'),
disableEscape: false,
header: translate('license.k+-already-registered-header'),
buttons: [
{
text: translate('license.overwrite'),
type: 'primary'
},
{
text: translate('license.cancel'),
type: 'secondary'
},
]
});
if (answer) {
this.forceSave();
return;
}
return;
} else {
this.toastr.error(translate('toasts.k+-error'));
}
});
this.toastr.error(err['error']);
}
} else {
this.toastr.error(translate('toasts.k+-error'));
}
}
forceSave() {
this.isSaving = false;
this.cdRef.markForCheck();
this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim())
.subscribe(_ => {
this.saveForm();
});
}
async deleteLicense() {
@ -110,10 +178,13 @@ export class LicenseComponent implements OnInit {
return;
}
this.accountService.deleteLicense().subscribe(() => {
this.licenseService.deleteLicense().subscribe(() => {
this.resetForm();
this.toggleViewMode();
this.validateLicense();
this.isViewMode = true;
this.licenseInfo = null;
this.hasLicense = false;
//this.hasValidLicense = false;
this.cdRef.markForCheck();
});
}
@ -122,28 +193,44 @@ export class LicenseComponent implements OnInit {
return;
}
this.accountService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()).subscribe(() => {
this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()).subscribe(() => {
this.toastr.success(translate('toasts.k+-reset-key-success'));
});
}
//
// validateLicense(forceCheck = false) {
// return of().pipe(
// startWith(null),
// tap(_ => {
// this.isChecking = true;
// this.cdRef.markForCheck();
// }),
// switchMap(_ => this.licenseService.licenseInfo(forceCheck)),
// tap(licenseInfo => {
// this.licenseInfo = licenseInfo;
// //this.hasValidLicense = licenseInfo?.isActive || false;
// this.isChecking = false;
// this.cdRef.markForCheck();
// })
// )
//
// }
updateEditMode(mode: boolean) {
this.isViewMode = !mode;
this.cdRef.markForCheck();
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
console.log('edit mode: ', !this.isViewMode)
this.cdRef.markForCheck();
this.resetForm();
}
validateLicense() {
this.isChecking = true;
this.accountService.hasValidLicense(true).subscribe(res => {
this.hasValidLicense = res;
this.isChecking = false;
this.cdRef.markForCheck();
});
}
updateEditMode(mode: boolean) {
this.isViewMode = mode;
toggleEmailShow() {
this.showEmail = !this.showEmail;
this.cdRef.markForCheck();
}
}

View file

@ -1,7 +0,0 @@
<app-license></app-license>
@if (accountService.hasValidLicense$ | async) {
<div class="mt-4">
<app-kavitaplus-metadata-breakdown-stats></app-kavitaplus-metadata-breakdown-stats>
</div>
}

View file

@ -1,23 +0,0 @@
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {AsyncPipe} from "@angular/common";
import {
KavitaplusMetadataBreakdownStatsComponent
} from "../../statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component";
import {LicenseComponent} from "../license/license.component";
import {AccountService} from "../../_services/account.service";
@Component({
selector: 'app-manage-kavitaplus',
standalone: true,
imports: [
AsyncPipe,
KavitaplusMetadataBreakdownStatsComponent,
LicenseComponent
],
templateUrl: './manage-kavitaplus.component.html',
styleUrl: './manage-kavitaplus.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageKavitaplusComponent {
protected readonly accountService = inject(AccountService);
}

View file

@ -1,9 +1,11 @@
<ng-container *ngIf="logs$ | async as items">
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; index as i">
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
</div>
@if (logs$ | async; as items) {
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
@for (item of scroll.viewPortItems; track item.timestamp) {
<div class="card col-auto mt-2 mb-2">
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
</div>
</virtual-scroller>
</ng-container>
}
</div>
</virtual-scroller>
}

View file

@ -4,7 +4,7 @@ import { BehaviorSubject, take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { environment } from 'src/environments/environment';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { NgIf, NgFor, AsyncPipe, DatePipe } from '@angular/common';
import { AsyncPipe, DatePipe } from '@angular/common';
interface LogMessage {
timestamp: string;
@ -18,7 +18,7 @@ interface LogMessage {
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss'],
standalone: true,
imports: [NgIf, VirtualScrollerModule, NgFor, AsyncPipe, DatePipe]
imports: [VirtualScrollerModule, AsyncPipe, DatePipe]
})
export class ManageLogsComponent implements OnInit, OnDestroy {

View file

@ -0,0 +1,80 @@
<ng-container *transloco="let t; read:'manage-matched-metadata'">
<p>{{t('description')}}</p>
<form [formGroup]="filterGroup">
<div class="row g-0">
<div class="col-auto ms-auto">
<label for="match-filter">Match State</label>
<select class="form-select" formControlName="matchState" id="match-filter">
@for(state of allMatchStates; track state) {
<option [value]="state">{{state | matchStateOption}}</option>
}
</select>
</div>
</div>
</form>
<ngx-datatable
class="bootstrap"
[rows]="data"
[loadingIndicator]="isLoading"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[limit]="15"
[footerHeight]="50"
>
<ngx-datatable-column name="lastModifiedUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-name-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<app-image [width]="'32px'" [height]="'32px'" [imageUrl]="imageService.getSeriesCoverImage(item.series.id)"></app-image>
<a class="ms-2" [href]="'/library/' + item.series.libraryId + '/series/' + item.series.id" target="_blank">{{item.series.name}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="status" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('status-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
@if (item.series.isBlacklisted) {
{{t('blacklist-status-label')}}
} @else if (item.series.dontMatch) {
{{t('dont-match-status-label')}}
} @else {
@if (item.isMatched) {
{{t('matched-status-label')}}
} @else {
{{t('unmatched-status-label')}}
}
}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('valid-until-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) {
{{null | defaultValue}}
} @else {
{{item.validUntilUtc | utcToLocalTime}}
}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="" [width]="20" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, item.series)"></app-card-actionables>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ng-container>

View file

@ -0,0 +1,9 @@
.table {
min-height: 60px;
width: 100%;
}
.tr {
height: 60px;
}

View file

@ -0,0 +1,128 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {LicenseService} from "../../_services/license.service";
import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Series} from "../../_models/series";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionService} from "../../_services/action.service";
import {ManageService} from "../../_services/manage.service";
import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component";
import {ManageMatchFilter} from "../../_models/kavitaplus/manage-match-filter";
import {allMatchStates, MatchStateOption} from "../../_models/kavitaplus/match-state-option";
import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@Component({
selector: 'app-manage-matched-metadata',
standalone: true,
imports: [
TranslocoDirective,
ImageComponent,
CardActionablesComponent,
VirtualScrollerModule,
ReactiveFormsModule,
Select2Module,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
],
templateUrl: './manage-matched-metadata.component.html',
styleUrl: './manage-matched-metadata.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageMatchedMetadataComponent implements OnInit {
protected readonly MatchState = MatchStateOption;
protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many
private readonly licenseService = inject(LicenseService);
private readonly actionFactory = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly router = inject(Router);
private readonly manageService = inject(ManageService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly imageService = inject(ImageService);
isLoading: boolean = true;
data: Array<ManageMatchSeries> = [];
actions: Array<ActionItem<Series>> = this.actionFactory.getSeriesActions(this.fixMatch.bind(this))
.filter(item => item.action === Action.Match);
filterGroup = new FormGroup({
'matchState': new FormControl(MatchStateOption.Error, []),
});
ngOnInit() {
this.licenseService.hasValidLicense$.subscribe(license => {
if (!license) {
// Navigate home
this.router.navigate(['/']);
return;
}
this.filterGroup.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(_ => {
this.isLoading = true;
this.cdRef.markForCheck();
}),
switchMap(_ => this.loadData()),
tap(_ => {
this.isLoading = false;
this.cdRef.markForCheck();
}),
).subscribe();
this.loadData().subscribe();
});
}
loadData() {
const filter: ManageMatchFilter = {
matchStateOption: parseInt(this.filterGroup.get('matchState')!.value + '', 10),
searchTerm: ''
};
this.isLoading = true;
this.data = [];
this.cdRef.markForCheck();
return this.manageService.getAllKavitaPlusSeries(filter).pipe(tap(data => {
this.data = [...data];
this.isLoading = false;
this.cdRef.markForCheck();
}));
}
performAction(action: ActionItem<Series>, series: Series) {
if (action.callback) {
action.callback(action, series);
}
}
fixMatch(actionItem: ActionItem<Series>, series: Series) {
this.actionService.matchSeries(series, result => {
if (!result) return;
this.loadData().subscribe();
});
}
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ScrobbleEventType = ScrobbleEventType;
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly ColumnMode = ColumnMode;
}

View file

@ -12,42 +12,41 @@
</div>
</div>
</form>
<div class="table-responsive-md">
<table class="table table-striped table-sm">
<thead #header>
<tr>
<th scope="col" sortable="filePath" (sort)="onSort($event)">
{{t('file-header')}}
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
{{t('comment-header')}}
</th>
<th scope="col" sortable="details" (sort)="onSort($event)">
{{t('created-header')}}
</th>
</tr>
</thead>
<tbody>
@for(item of data | filter: filterList; track item.filePath; let index = $index) {
<tr>
<td>
{{item.filePath}}
</td>
<td>
{{item.comment}}
</td>
<td>
{{item.createdUtc | utcToLocalTime | defaultDate}}
</td>
</tr>
} @empty {
@if (isLoading) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
} @else {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
</tbody>
</table>
</div>
<ngx-datatable
class="bootstrap"
[rows]="data | filter: filterList"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column name="filePath" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('file-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.filePath}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="comment" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.createdUtc | utcToLocalTime | defaultDate}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ng-container>

View file

@ -19,10 +19,11 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { FilterPipe } from '../../_pipes/filter.pipe';
import { LoadingComponent } from '../../shared/loading/loading.component';
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@Component({
selector: 'app-manage-media-issues',
@ -30,7 +31,7 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
styleUrls: ['./manage-media-issues.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe]
imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule]
})
export class ManageMediaIssuesComponent implements OnInit {
@ -100,4 +101,6 @@ export class ManageMediaIssuesComponent implements OnInit {
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.filePath.toLowerCase().indexOf(query) >= 0 || listItem.details.indexOf(query) >= 0;
}
protected readonly ColumnMode = ColumnMode;
protected readonly translate = translate;
}

View file

@ -13,53 +13,55 @@
</div>
</div>
</form>
<table class="table table-striped table-sm">
<thead #header>
<tr>
<th scope="col" sortable="seriesId" (sort)="onSort($event)">
{{t('series-header')}}
</th>
<th scope="col" sortable="created" (sort)="onSort($event)">
{{t('created-header')}}
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
{{t('comment-header')}}
</th>
<th scope="col">
{{t('edit-header')}}
</th>
</tr>
</thead>
<tbody #container>
@if (isLoading) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
} @else {
@if(data | filter: filterList; as filteredData) {
@for(item of filteredData; track item.seriesId; let i = $index) {
<tr>
<td>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</td>
<td>
{{item.createdUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.comment}}
</td>
<td>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
</button>
</td>
</tr>
}
@empty {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
}
</tbody>
</table>
<ngx-datatable
class="bootstrap"
[rows]="data | filter: filterList"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column name="seriesId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.createdUtc | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="comment" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="edit" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('edit-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
</button>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ng-container>

View file

@ -30,11 +30,13 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {DevicePlatformPipe} from "../../_pipes/device-platform.pipe";
@Component({
selector: 'app-manage-scrobble-errors',
standalone: true,
imports: [ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe],
imports: [ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, DevicePlatformPipe, NgxDatatableModule],
templateUrl: './manage-scrobble-errors.component.html',
styleUrls: ['./manage-scrobble-errors.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -118,4 +120,5 @@ export class ManageScrobbleErrorsComponent implements OnInit {
}
protected readonly filter = filter;
protected readonly ColumnMode = ColumnMode;
}

View file

@ -1,9 +1,5 @@
<app-user-scrobble-history></app-user-scrobble-history>
<div class="setting-section-break"></div>
<div class="mt-4">
<app-user-holds></app-user-holds>
</div>
@if(accountService.isAdmin$ | async) {
<div class="setting-section-break"></div>

View file

@ -2,8 +2,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec
import {ManageScrobbleErrorsComponent} from "../manage-scrobble-errors/manage-scrobble-errors.component";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
import {map, shareReplay} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobblingHoldsComponent} from "../../user-settings/user-holds/scrobbling-holds.component";
import {
UserScrobbleHistoryComponent
@ -15,7 +13,6 @@ import {
imports: [
ManageScrobbleErrorsComponent,
AsyncPipe,
ScrobblingHoldsComponent,
UserScrobbleHistoryComponent
],
templateUrl: './manage-scrobbling.component.html',

View file

@ -1,22 +1,37 @@
<ng-container *transloco="let t; read: 'manage-system'">
<div class="container-fluid">
<div class="mb-3" *ngIf="serverInfo">
<h3>{{t('title')}}</h3>
<dl>
<dt>{{t('version-title')}}</dt>
<dd>{{serverInfo.kavitaVersion}}</dd>
@if (serverInfo) {
<div class="mb-3">
<h3>{{t('title')}}</h3>
<dt>{{t('installId-title')}}</dt>
<dd>{{serverInfo.installId}}</dd>
<div class="row">
<div class="col-4">
<div>{{t('version-title')}}</div>
<div>{{serverInfo.kavitaVersion}}</div>
</div>
<dt>{{t('first-install-version-title')}}</dt>
<dd>{{serverInfo.firstInstallVersion | defaultValue}}</dd>
<div class="col-4">
<div>{{t('installId-title')}}</div>
<div>{{serverInfo.installId}}</div>
</div>
</div>
<dt>{{t('first-install-date-title')}}</dt>
<dd>{{serverInfo.firstInstallDate | date:'shortDate'}}</dd>
</dl>
</div>
<div class="row">
<div class="col-4">
<div>{{t('first-install-version-title')}}</div>
<div>{{serverInfo.firstInstallVersion}}</div>
</div>
<div class="col-4">
<div>{{t('first-install-date-title')}}</div>
<div>{{serverInfo.firstInstallDate | date:'shortDate'}}</div>
</div>
</div>
</div>
<div class="setting-section-break"></div>
}
<div class="mb-3">
<h3>{{t('more-info-title')}}</h3>
@ -50,6 +65,8 @@
</div>
</div>
<div class="setting-section-break"></div>
<div class="mb-3">
<h3>{{t('updates-title')}}</h3>
<app-changelog></app-changelog>

View file

@ -1,178 +1,194 @@
<ng-container *transloco="let t; read: 'manage-tasks-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
@if (serverSettings) {
<form [formGroup]="settingsForm">
<h4>{{t('title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskScan'); as formControl) {
<app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
<h4>{{t('title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskScan'); as formControl) {
<app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</select>
</ng-template>
<ng-template #edit>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-scan" formControlName="taskScanCustom"
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
aria-describedby="task-scan-validations">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-scan-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskScanCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</select>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskBackup'); as formControl) {
<app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</ng-template>
<ng-template #edit>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-scan" formControlName="taskScanCustom"
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
aria-describedby="task-scan-validations">
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-scan-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskScanCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-backup" formControlName="taskBackupCustom"
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
aria-describedby="task-scan-validations">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-backup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskBackupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskCleanup'); as formControl) {
<app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="settings-tasks-cleanup-help" formControlName="taskCleanup" id="settings-tasks-cleanup">
@for(freq of taskFrequenciesForCleanup; track freq) {
<option [value]="freq">{{t(freq)}}</option>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskBackup'); as formControl) {
<app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</select>
</ng-template>
<ng-template #edit>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-cleanup" formControlName="taskCleanupCustom"
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
aria-describedby="task-scan-validations">
@if (settingsForm.get('taskCleanupCustom')?.invalid) {
<div id="task-cleanup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskCleanupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
</select>
<div class="setting-section-break"></div>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-backup" formControlName="taskBackupCustom"
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
aria-describedby="task-scan-validations">
<h4>{{t('adhoc-tasks-title')}}</h4>
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-backup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskBackupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
@for(task of adhocTasks; track task.name; let idx = $index) {
<div class="mt-4 mb-4">
<app-setting-button [subtitle]="t(task.description)">
<button class="btn btn-secondary btn-sm mb-2" (click)="runAdhoc(task)">{{t(task.name)}}</button>
</app-setting-button>
</div>
}
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskCleanup'); as formControl) {
<app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')">
<ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}})
} @else {
{{t(formControl.value)}}
}
</ng-template>
<ng-template #edit>
<h4>{{t('recurring-tasks-title')}}</h4>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">{{t('job-title-header')}}</th>
<th scope="col">{{t('last-executed-header')}}</th>
<th scope="col">{{t('cron-header')}}</th>
</tr>
</thead>
<tbody>
@for(task of recurringTasks$ | async; track task; let idx = $index) {
<tr>
<td>
{{task.title | titlecase}}
</td>
<td>
{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}
</td>
<td>{{task.cron}}</td>
</tr>
}
</tbody>
</table>
</form>
<select class="form-select" aria-describedby="settings-tasks-cleanup-help" formControlName="taskCleanup" id="settings-tasks-cleanup">
@for(freq of taskFrequenciesForCleanup; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</select>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-cleanup" formControlName="taskCleanupCustom"
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
aria-describedby="task-scan-validations">
@if (settingsForm.get('taskCleanupCustom')?.invalid) {
<div id="task-cleanup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskCleanupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('adhoc-tasks-title')}}</h4>
@for(task of adhocTasks; track task.name; let idx = $index) {
<div class="mt-4 mb-4">
<app-setting-button [subtitle]="t(task.description)">
<button class="btn btn-secondary btn-sm mb-2" (click)="runAdhoc(task)">{{t(task.name)}}</button>
</app-setting-button>
</div>
}
<div class="setting-section-break"></div>
<h4>{{t('recurring-tasks-title')}}</h4>
<ngx-datatable
class="bootstrap"
[rows]="recurringTasks$ | async"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column name="title" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('job-title-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.title | titlecase}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="lastExecutionUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-executed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.lastExecutionUtc | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="cron" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('cron-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.cron}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</form>
}
</div>
</ng-container>

View file

@ -3,15 +3,15 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/
import {ToastrService} from 'ngx-toastr';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {shareReplay, take} from 'rxjs/operators';
import {shareReplay} from 'rxjs/operators';
import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job';
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule} from "@jsverse/transloco";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -21,6 +21,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
import {ConfirmService} from "../../shared/confirm.service";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
interface AdhocTask {
name: string;
@ -36,14 +37,13 @@ interface AdhocTask {
styleUrls: ['./manage-tasks-settings.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe,
TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent]
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule]
})
export class ManageTasksSettingsComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly confirmService = inject(ConfirmService);
private readonly settingsService = inject(SettingsService);
private readonly toastr = inject(ToastrService);
private readonly serverService = inject(ServerService);
@ -323,4 +323,5 @@ export class ManageTasksSettingsComponent implements OnInit {
}
protected readonly ColumnMode = ColumnMode;
}

View file

@ -0,0 +1,52 @@
<ng-container *transloco="let t; read:'manage-user-tokens'">
<div class="container-fluid">
<p>{{t('description')}}</p>
<ngx-datatable
class="bootstrap"
[rows]="users"
[columnMode]="ColumnMode.force"
rowHeight="auto"
[footerHeight]="50"
>
<ngx-datatable-column name="username" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('username-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.username}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="aniListValidUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('anilist-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
@if (item.isAniListTokenSet) {
{{t('token-set-label')}} <span class="text-muted ms-1">{{t('expires-label', {date: item.aniListValidUntilUtc | utcToLocalTime})}}</span>
} @else {
{{null | defaultValue}}
}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('mal-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@if (item.isMalTokenSet) {
{{t('token-set-label')}}
} @else {
{{null | defaultValue}}
}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</div>
</ng-container>

View file

@ -0,0 +1,58 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {MemberService} from "../../_services/member.service";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {UserTokenInfo} from "../../_models/kavitaplus/user-token-info";
import {ServerService} from "../../_services/server.service";
import {SettingsService} from "../settings.service";
import {MessageHubService} from "../../_services/message-hub.service";
import {ConfirmService} from "../../shared/confirm.service";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {ImageComponent} from "../../shared/image/image.component";
@Component({
selector: 'app-manage-user-tokens',
standalone: true,
imports: [
TranslocoDirective,
DefaultValuePipe,
LoadingComponent,
UtcToLocalTimePipe,
VirtualScrollerModule,
CardActionablesComponent,
ImageComponent,
NgxDatatableModule
],
templateUrl: './manage-user-tokens.component.html',
styleUrl: './manage-user-tokens.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageUserTokensComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly memberService = inject(MemberService);
isLoading = true;
users: UserTokenInfo[] = [];
ngOnInit() {
this.loadData();
}
loadData() {
this.isLoading = true;
this.cdRef.markForCheck();
this.memberService.getUserTokenInfo().subscribe(users => {
this.users = users;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
protected readonly ColumnMode = ColumnMode;
}

View file

@ -5,6 +5,8 @@
</button>
</div>
<table class="table table-striped">
<thead>
<tr>

View file

@ -0,0 +1,37 @@
<ng-container *transloco="let t; read:'changelog-update-item'">
@if (update) {
<div class="update-details">
@if (update.blogPart) {
<div class="blog-content" [innerHTML]="update.blogPart | safeHtml"></div>
<div class="setting-section-break"></div>
}
<div class="mt-4">
<app-update-section [items]="update.added" [title]="t('added')"></app-update-section>
<app-update-section [items]="update.changed" [title]="t('changed')"></app-update-section>
<app-update-section [items]="update.fixed" [title]="t('fixed')"></app-update-section>
<app-update-section [items]="update.developer" [title]="t('developer')"></app-update-section>
<app-update-section [items]="update.theme" [title]="t('theme')"></app-update-section>
<app-update-section [items]="update.removed" [title]="t('removed')"></app-update-section>
<app-update-section [items]="update.api" [title]="t('api')"></app-update-section>
</div>
@if (showExtras) {
<div class="setting-section-break"></div>
<div class="mb-1 mt-2">
<span class="text-muted">{{t('published-label')}}{{update.publishDate | date: 'short'}}</span>
@if (!update.isDocker && (accountService.isAdmin$ | async)) {
@if (update.updateVersion === update.currentVersion) {
<a href="{{update.updateUrl}}" class="btn disabled btn-{{index === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('installed')}}</a>
} @else {
<a href="{{update.updateUrl}}" class="btn btn-{{index === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('download')}}</a>
}
}
</div>
}
</div>
}
</ng-container>

View file

@ -0,0 +1,13 @@
.update-details {
border-radius: 0.5rem;
}
.blog-content {
margin-bottom: 1.5rem;
line-height: 1.6;
word-wrap: break-word;
img {
max-width: 100% !important;
}
}

View file

@ -0,0 +1,30 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {UpdateSectionComponent} from "../update-section/update-section.component";
import {AsyncPipe, DatePipe} from "@angular/common";
import {UpdateVersionEvent} from "../../../_models/events/update-version-event";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../../_services/account.service";
@Component({
selector: 'app-changelog-update-item',
standalone: true,
imports: [
SafeHtmlPipe,
UpdateSectionComponent,
AsyncPipe,
DatePipe,
TranslocoDirective
],
templateUrl: './changelog-update-item.component.html',
styleUrl: './changelog-update-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangelogUpdateItemComponent {
protected readonly accountService = inject(AccountService);
@Input({required:true}) update: UpdateVersionEvent | null = null;
@Input() index: number = 0;
@Input() showExtras: boolean = true;
}

View file

@ -1,37 +1,31 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'changelog'">
<div class="changelog">
<p class="pb-2">
{{t('description', {installed: ''})}}
<span class="badge bg-secondary">{{t('installed')}}</span>
{{t('description-continued', {installed: ''})}}
</p>
@for(update of updates; track update; let indx = $index) {
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp;
@if (update.isOnNightlyInRelease) {
<span class="badge bg-secondary">{{t('nightly', {version: update.currentVersion})}}</span>
} @else if (update.isReleaseEqual) {
<span class="badge bg-secondary">{{t('installed')}}</span>
} @else if (update.isReleaseNewer && indx === 0) {
<span class="badge bg-secondary">{{t('available')}}</span>
}
</h4>
<h6 class="card-subtitle mb-1 mt-1 text-muted">{{t('published-label')}}{{update.publishDate | date: 'short'}}</h6>
<pre class="card-text update-body">
<app-read-more [text]="update.updateBody" [maxLength]="500"></app-read-more>
</pre>
@if (!update.isDocker && (accountService.isAdmin$ | async)) {
@if (update.updateVersion === update.currentVersion) {
<a href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('installed')}}</a>
} @else {
<a href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('download')}}</a>
}
}
<div ngbAccordion class="mb-2">
<div ngbAccordionItem class="p-2">
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
<h4 class="changelog-header">{{update.updateTitle}}&nbsp;
@if (update.isOnNightlyInRelease) {
<span class="badge bg-secondary">{{t('nightly', {version: update.currentVersion})}}</span>
} @else if (update.isReleaseEqual) {
<span class="badge bg-secondary">{{t('installed')}}</span>
} @else if (update.isReleaseNewer && indx === 0) {
<span class="badge bg-secondary">{{t('available')}}</span>
}
</h4>
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<app-changelog-update-item [update]="update" [showExtras]="true" [index]="indx"></app-changelog-update-item>
</ng-template>
</div>
</div>
</div>
</div>
}

View file

@ -1,25 +1,8 @@
.update-body {
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
.update-details {
border-radius: 0.5rem;
}
::ng-deep .changelog {
h1 {
font-size: 26px;
}
p, ul {
margin-bottom: 0px;
}
img {
max-width: 100% !important;
}
.changelog-header {
color: var(--body-text-color);
}

View file

@ -2,17 +2,25 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} f
import {UpdateVersionEvent} from 'src/app/_models/events/update-version-event';
import {ServerService} from 'src/app/_services/server.service';
import {LoadingComponent} from '../../../shared/loading/loading.component';
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
import {AsyncPipe, DatePipe} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../../_services/account.service";
import {
NgbAccordionBody,
NgbAccordionButton, NgbAccordionCollapse,
NgbAccordionDirective,
NgbAccordionHeader,
NgbAccordionItem
} from "@ng-bootstrap/ng-bootstrap";
import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component";
@Component({
selector: 'app-changelog',
templateUrl: './changelog.component.html',
styleUrls: ['./changelog.component.scss'],
standalone: true,
imports: [ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective, AsyncPipe],
imports: [LoadingComponent, TranslocoDirective, NgbAccordionDirective,
NgbAccordionItem, NgbAccordionButton, NgbAccordionHeader, NgbAccordionCollapse, NgbAccordionBody, ChangelogUpdateItemComponent],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangelogComponent implements OnInit {
@ -25,7 +33,7 @@ export class ChangelogComponent implements OnInit {
isLoading: boolean = true;
ngOnInit(): void {
this.serverService.getChangelog().subscribe(updates => {
this.serverService.getChangelog(10).subscribe(updates => {
this.updates = updates;
this.isLoading = false;
this.cdRef.markForCheck();

View file

@ -0,0 +1,16 @@
<ng-container *transloco="let t; read:'new-version-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
@if (update) {
<app-changelog-update-item [update]="update" [showExtras]="false"></app-changelog-update-item>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="refresh()">{{t('refresh')}}</button>
</div>
</ng-container>

View file

@ -0,0 +1,61 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {UpdateVersionEvent} from "../../../_models/events/update-version-event";
import {UpdateSectionComponent} from "../update-section/update-section.component";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {VersionService} from "../../../_services/version.service";
import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component";
@Component({
selector: 'app-new-update-modal',
standalone: true,
imports: [
TranslocoDirective,
UpdateSectionComponent,
SafeHtmlPipe,
ChangelogUpdateItemComponent
],
templateUrl: './new-update-modal.component.html',
styleUrl: './new-update-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NewUpdateModalComponent {
private readonly ngbModal = inject(NgbActiveModal);
private readonly translocoService = inject(TranslocoService);
@Input({required: true}) version: string = '';
@Input({required: true}) update: UpdateVersionEvent | null = null;
close() {
this.ngbModal.dismiss();
}
refresh() {
this.bustLocaleCache();
this.applyUpdate(this.version);
// Refresh manually
location.reload();
}
private applyUpdate(version: string): void {
this.bustLocaleCache();
console.log('Setting version key: ', version);
localStorage.setItem(VersionService.versionKey, version);
location.reload();
}
private bustLocaleCache() {
localStorage.removeItem('@transloco/translations/timestamp');
localStorage.removeItem('@transloco/translations');
localStorage.removeItem('translocoLang');
const locale = localStorage.getItem('kavita-locale') || 'en';
(this.translocoService as any).cache.delete(locale);
(this.translocoService as any).cache.clear();
// TODO: Retrigger transloco
this.translocoService.setActiveLang(locale);
}
}

View file

@ -0,0 +1,10 @@
@if (items.length > 0) {
<h5>{{ title }}
<span class="badge bg-primary ms-2">{{items.length}}</span>
</h5>
<ul>
@for(item of items; track item;) {
<li class="code">{{item}}</li>
}
</ul>
}

View file

@ -0,0 +1,16 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
@Component({
selector: 'app-update-section',
standalone: true,
imports: [],
templateUrl: './update-section.component.html',
styleUrl: './update-section.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UpdateSectionComponent {
@Input({required: true}) items: Array<string> = [];
@Input({required: true}) title: string = '';
// TODO: Implement a read-more-list so that we by default show a configurable number
}

View file

@ -23,6 +23,9 @@ import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-m
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
import {TranslocoService} from "@jsverse/transloco";
import {User} from "./_models/user";
import {VersionService} from "./_services/version.service";
import {LicenseService} from "./_services/license.service";
@Component({
selector: 'app-root',
@ -48,13 +51,15 @@ export class AppComponent implements OnInit {
private readonly themeService = inject(ThemeService);
private readonly document = inject(DOCUMENT);
private readonly translocoService = inject(TranslocoService);
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
private readonly licenseService = inject(LicenseService);
protected readonly Breakpoint = Breakpoint;
constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) {
modalConfig.fullscreen = 'md';
modalConfig.fullscreen = 'lg';
// Setup default rating config
ratingConfig.max = 5;
@ -119,46 +124,6 @@ export class AppComponent implements OnInit {
// Bootstrap anything that's needed
this.themeService.getThemes().subscribe();
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
// Get the server version, compare vs localStorage, and if different bust locale cache
const versionKey = 'kavita--version';
this.serverService.getVersion(user.apiKey).subscribe(version => {
const cachedVersion = localStorage.getItem(versionKey);
console.log('Kavita version: ', version, ' Running version: ', cachedVersion);
if (cachedVersion == null || cachedVersion != version) {
// Bust locale cache
this.bustLocaleCache();
localStorage.setItem(versionKey, version);
location.reload();
}
localStorage.setItem(versionKey, version);
});
// Every hour, have the UI check for an update. People seriously stay out of date
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionOutOfDate => {
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
}),
tap(versionOutOfDate => {
if (!this.ngbModal.hasOpenModals()) {
const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
ref.componentInstance.versionsOutOfDate = versionOutOfDate;
}
})
)
.subscribe();
}
private bustLocaleCache() {
localStorage.removeItem('@transloco/translations/timestamp');
localStorage.removeItem('@transloco/translations');
localStorage.removeItem('translocoLang');
(this.translocoService as any).cache.delete(localStorage.getItem('kavita-locale') || 'en');
(this.translocoService as any).cache.clear();
this.licenseService.licenseInfo().subscribe();
}
}

View file

@ -32,63 +32,63 @@ export const BookPaperTheme = `
--accordion-active-body-bg-color: #F1E4D5;
/* Buttons */
--btn-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-primary-text-color: white;
--btn-primary-bg-color: var(--primary-color);
--btn-primary-border-color: var(--primary-color);
--btn-primary-hover-text-color: white;
--btn-primary-hover-bg-color: var(--primary-color-darker-shade);
--btn-primary-hover-border-color: var(--primary-color-darker-shade);
--btn-alt-bg-color: #424c72;
--btn-alt-border-color: #444f75;
--btn-alt-hover-bg-color: #3b4466;
--btn-alt-focus-bg-color: #343c59;
--btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-fa-icon-color: black;
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
--btn-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-primary-text-color: white;
--btn-primary-bg-color: var(--primary-color);
--btn-primary-border-color: var(--primary-color);
--btn-primary-hover-text-color: white;
--btn-primary-hover-bg-color: var(--primary-color-darker-shade);
--btn-primary-hover-border-color: var(--primary-color-darker-shade);
--btn-alt-bg-color: #424c72;
--btn-alt-border-color: #444f75;
--btn-alt-hover-bg-color: #3b4466;
--btn-alt-focus-bg-color: #343c59;
--btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-fa-icon-color: black;
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
/* Inputs */
--input-bg-color: white;
--input-bg-readonly-color: #F1E4D5;
--input-focused-border-color: #ccc;
--input-placeholder-color: black;
--input-border-color: #ccc;
--input-text-color: black;
--input-focus-boxshadow-color: rgb(255 255 255 / 50%);
/* Inputs */
--input-bg-color: white;
--input-bg-readonly-color: #F1E4D5;
--input-focused-border-color: #ccc;
--input-placeholder-color: black;
--input-border-color: #ccc;
--input-text-color: black;
--input-focus-boxshadow-color: rgb(255 255 255 / 50%);
/* Nav (Tabs) */
--nav-tab-border-color: rgba(44, 118, 88, 0.7);
--nav-tab-text-color: var(--body-text-color);
--nav-tab-bg-color: var(--primary-color);
--nav-tab-hover-border-color: var(--primary-color);
--nav-tab-active-text-color: white;
--nav-tab-border-hover-color: transparent;
--nav-tab-hover-text-color: var(--body-text-color);
--nav-tab-hover-bg-color: transparent;
--nav-tab-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-border-bottom: rgba(44, 118, 88, 0.7);
--nav-tab-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-bottom: var(--bs-body-bg);
--nav-tab-hover-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-active-hover-bg-color: var(--primary-color);
--nav-link-bg-color: var(--primary-color);
--nav-link-active-text-color: white;
--nav-link-text-color: white;
/* Nav (Tabs) */
--nav-tab-border-color: rgba(44, 118, 88, 0.7);
--nav-tab-text-color: var(--body-text-color);
--nav-tab-bg-color: var(--primary-color);
--nav-tab-hover-border-color: var(--primary-color);
--nav-tab-active-text-color: white;
--nav-tab-border-hover-color: transparent;
--nav-tab-hover-text-color: var(--body-text-color);
--nav-tab-hover-bg-color: transparent;
--nav-tab-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-border-bottom: rgba(44, 118, 88, 0.7);
--nav-tab-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-bottom: var(--bs-body-bg);
--nav-tab-hover-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-active-hover-bg-color: var(--primary-color);
--nav-link-bg-color: var(--primary-color);
--nav-link-active-text-color: white;
--nav-link-text-color: white;
/* Reading Bar */
--br-actionbar-button-hover-border-color: #6c757d;
--br-actionbar-bg-color: #F1E4D5;
/* Reading Bar */
--br-actionbar-button-hover-border-color: #6c757d;
--br-actionbar-bg-color: #F1E4D5;
/* Drawer */
--drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%);
/* Drawer */
--drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%);
/* Custom variables */
--theme-bg-color: #fff3c9;
/* Custom variables */
--theme-bg-color: #fff3c9;
}
.reader-container {
@ -115,9 +115,8 @@ export const BookPaperTheme = `
}
.book-content img, .book-content img[src] {
z-index: 1;
filter: brightness(0.85) !important;
background-color: initial !important;
z-index: 1;
background-color: initial !important;
}
@ -129,8 +128,12 @@ background-color: initial !important;
color: #dcdcdc !important;
}
.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(240, 50, 50) !important}
.book-content :link:not(cite), :link .book-content *:not(cite) {color: #00f !important}
.book-content :visited, .book-content :visited *, .book-content :visited *[class] {
color: rgb(240, 50, 50) !important;
}
.book-content :link:not(cite), :link .book-content *:not(cite) {
color: #00f !important;
}
.btn-check:checked + .btn {
color: white;

View file

@ -35,64 +35,64 @@ export const BookWhiteTheme = `
--accordion-active-body-bg-color: white;
/* Buttons */
--btn-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-primary-text-color: white;
--btn-primary-bg-color: var(--primary-color);
--btn-primary-border-color: var(--primary-color);
--btn-primary-hover-text-color: white;
--btn-primary-hover-bg-color: var(--primary-color-darker-shade);
--btn-primary-hover-border-color: var(--primary-color-darker-shade);
--btn-alt-bg-color: #424c72;
--btn-alt-border-color: #444f75;
--btn-alt-hover-bg-color: #3b4466;
--btn-alt-focus-bg-color: #343c59;
--btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-fa-icon-color: black;
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
--btn-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-primary-text-color: white;
--btn-primary-bg-color: var(--primary-color);
--btn-primary-border-color: var(--primary-color);
--btn-primary-hover-text-color: white;
--btn-primary-hover-bg-color: var(--primary-color-darker-shade);
--btn-primary-hover-border-color: var(--primary-color-darker-shade);
--btn-alt-bg-color: #424c72;
--btn-alt-border-color: #444f75;
--btn-alt-hover-bg-color: #3b4466;
--btn-alt-focus-bg-color: #343c59;
--btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%);
--btn-fa-icon-color: black;
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
/* Inputs */
--input-bg-color: white;
--input-bg-readonly-color: white;
--input-focused-border-color: #ccc;
--input-text-color: black;
--input-placeholder-color: black;
--input-border-color: #ccc;
--input-focus-boxshadow-color: rgb(255 255 255 / 50%);
/* Inputs */
--input-bg-color: white;
--input-bg-readonly-color: white;
--input-focused-border-color: #ccc;
--input-text-color: black;
--input-placeholder-color: black;
--input-border-color: #ccc;
--input-focus-boxshadow-color: rgb(255 255 255 / 50%);
/* Nav (Tabs) */
--nav-tab-border-color: rgba(44, 118, 88, 0.7);
--nav-tab-text-color: var(--body-text-color);
--nav-tab-bg-color: var(--primary-color);
--nav-tab-hover-border-color: var(--primary-color);
--nav-tab-active-text-color: white;
--nav-tab-border-hover-color: transparent;
--nav-tab-hover-text-color: var(--body-text-color);
--nav-tab-hover-bg-color: transparent;
--nav-tab-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-border-bottom: rgba(44, 118, 88, 0.7);
--nav-tab-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-bottom: var(--bs-body-bg);
--nav-tab-hover-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-active-hover-bg-color: var(--primary-color);
--nav-link-bg-color: var(--primary-color);
--nav-link-active-text-color: white;
--nav-link-text-color: white;
/* Nav (Tabs) */
--nav-tab-border-color: rgba(44, 118, 88, 0.7);
--nav-tab-text-color: var(--body-text-color);
--nav-tab-bg-color: var(--primary-color);
--nav-tab-hover-border-color: var(--primary-color);
--nav-tab-active-text-color: white;
--nav-tab-border-hover-color: transparent;
--nav-tab-hover-text-color: var(--body-text-color);
--nav-tab-hover-bg-color: transparent;
--nav-tab-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-border-bottom: rgba(44, 118, 88, 0.7);
--nav-tab-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-top: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-left: rgba(44, 118, 88, 0.7);
--nav-tab-hover-border-bottom: var(--bs-body-bg);
--nav-tab-hover-border-right: rgba(44, 118, 88, 0.7);
--nav-tab-active-hover-bg-color: var(--primary-color);
--nav-link-bg-color: var(--primary-color);
--nav-link-active-text-color: white;
--nav-link-text-color: white;
/* Reading Bar */
--br-actionbar-button-text-color: black;
--br-actionbar-button-hover-border-color: #6c757d;
--br-actionbar-bg-color: white;
/* Reading Bar */
--br-actionbar-button-text-color: black;
--br-actionbar-button-hover-border-color: #6c757d;
--br-actionbar-bg-color: white;
/* Drawer */
--drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%);
--drawer-pagination-border: 1px solid rgb(0 0 0 / 13%);
/* Drawer */
--drawer-pagination-horizontal-rule: inset 0 -1px 0 rgb(0 0 0 / 13%);
--drawer-pagination-border: 1px solid rgb(0 0 0 / 13%);
}
.reader-container {
@ -115,22 +115,25 @@ export const BookWhiteTheme = `
}
.book-content img, .book-content img[src] {
z-index: 1;
filter: brightness(0.85) !important;
background-color: initial !important;
z-index: 1;
background-color: initial !important;
}
.book-content *:not(code), .book-content *:not(a) {
background-color: white;
box-shadow: none;
text-shadow: none;
border-radius: unset;
color: #dcdcdc !important;
background-color: white;
box-shadow: none;
text-shadow: none;
border-radius: unset;
color: #dcdcdc !important;
}
.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(240, 50, 50) !important}
.book-content :link:not(cite), :link .book-content *:not(cite) {color: #00f !important}
.book-content :visited, .book-content :visited *, .book-content :visited *[class] {
color: rgb(240, 50, 50) !important;
}
.book-content :link:not(cite), :link .book-content *:not(cite) {
color: #00f !important;
}
.btn-check:checked + .btn {
color: white;

View file

@ -731,18 +731,6 @@
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
@if (accountService.hasValidLicense$ | async) {
<button type="button" class="btn btn-light" (click)="forceScan()" position="above"
[ngbTooltip]="t('force-refresh-tooltip')">
@if (forceIsLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span>
</div>
} @else {
{{t('force-refresh')}}
}
</button>
}
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">{{t('save')}}</button>
</div>

View file

@ -62,6 +62,7 @@ import {ActionService} from "../../../_services/action.service";
import {DownloadService} from "../../../shared/_services/download.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {ReadTimePipe} from "../../../_pipes/read-time.pipe";
import {LicenseService} from "../../../_services/license.service";
enum TabID {
General = 0,
@ -134,6 +135,7 @@ export class EditSeriesModalComponent implements OnInit {
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
public readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
private readonly actionFactoryService = inject(ActionFactoryService);

View file

@ -45,7 +45,7 @@
<div class="card-overlay"></div>
@if (showReadButton) {
@if (showReadButton && !bulkSelectionService.hasSelections()) {
<div class="series overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;">

View file

@ -44,16 +44,19 @@
}
<div class="card-overlay"></div>
<div class="chapter overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;" (click)="read($event)">
<!-- Card Image -->
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
@if (!bulkSelectionService.hasSelections()) {
<div class="chapter overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;" (click)="read($event)">
<!-- Card Image -->
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</div>
</div>
</div>
}
</div>
@if (chapter.isSpecial) {

View file

@ -1,4 +1,55 @@
<ng-container *transloco="let t; read: 'edit-chapter-progress'">
<!-- <ngx-datatable-->
<!-- class="bootstrap"-->
<!-- [rows]="items.controls"-->
<!-- [columnMode]="ColumnMode.flex"-->
<!-- rowHeight="auto"-->
<!-- [footerHeight]="50"-->
<!-- [limit]="15"-->
<!-- >-->
<!-- <ngx-datatable-column name="" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">-->
<!-- <ng-template let-column="column" ngx-datatable-header-template>-->
<!-- {{t('user-header')}}-->
<!-- </ng-template>-->
<!-- <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>-->
<!-- {{progressEvents[idx].userName | sentenceCase}}-->
<!-- </ng-template>-->
<!-- </ngx-datatable-column>-->
<!-- <ngx-datatable-column name="" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">-->
<!-- <ng-template let-column="column" ngx-datatable-header-template>-->
<!-- {{t('page-read-header')}}-->
<!-- </ng-template>-->
<!-- <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>-->
<!-- @if(editMode[idx]) {-->
<!-- <input type="number" formControlName="pagesRead" class="form-control" inputmode="numeric"/>-->
<!-- } @else {-->
<!-- {{progressEvents[idx].pagesRead}}-->
<!-- }-->
<!-- </ng-template>-->
<!-- </ngx-datatable-column>-->
<!-- <ngx-datatable-column name="" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">-->
<!-- <ng-template let-column="column" ngx-datatable-header-template>-->
<!-- {{t('date-created-header')}}-->
<!-- </ng-template>-->
<!-- <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>-->
<!-- {{progressEvents[idx].createdUtc | utcToLocalTime | date:'shortDate' | defaultDate}}-->
<!-- </ng-template>-->
<!-- </ngx-datatable-column>-->
<!-- <ngx-datatable-column name="" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">-->
<!-- <ng-template let-column="column" ngx-datatable-header-template>-->
<!-- {{t('date-updated-header')}}-->
<!-- </ng-template>-->
<!-- <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>-->
<!-- {{progressEvents[idx].lastModifiedUtc | utcToLocalTime | date:'shortDate' | defaultDate}}-->
<!-- </ng-template>-->
<!-- </ngx-datatable-column>-->
<!-- </ngx-datatable>-->
<table class="table table-striped" [formGroup]="formGroup">
<thead>
<tr>

View file

@ -9,6 +9,7 @@ import {TranslocoDirective} from "@jsverse/transloco";
import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
@Component({
selector: 'app-edit-chapter-progress',
@ -23,7 +24,8 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
ReactiveFormsModule,
SentenceCasePipe,
DatePipe,
DefaultDatePipe
DefaultDatePipe,
NgxDatatableModule
],
templateUrl: './edit-chapter-progress.component.html',
styleUrl: './edit-chapter-progress.component.scss',
@ -82,4 +84,5 @@ export class EditChapterProgressComponent implements OnInit {
this.cdRef.markForCheck();
}
protected readonly ColumnMode = ColumnMode;
}

View file

@ -44,16 +44,19 @@
}
<div class="card-overlay"></div>
<div class="series overlay-information">
<div class="overlay-information--centered">
@if (!bulkSelectionService.hasSelections()) {
<div class="series overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;" (click)="read($event)">
<!-- Card Image -->
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</div>
</div>
</div>
}
</div>
<div class="card-body meta-title" [ngbTooltip]="series.localizedName.length > 34 ? series.localizedName : null">
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">

View file

@ -38,16 +38,18 @@
}
<div class="card-overlay"></div>
<div class="volume overlay-information" (click)="read($event)">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;">
<!-- Card Image -->
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
@if (!bulkSelectionService.hasSelections()) {
<div class="volume overlay-information" (click)="read($event)">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;">
<!-- Card Image -->
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</div>
</div>
</div>
}
</div>
@if (libraryType === LibraryType.LightNovel || libraryType === LibraryType.Book) {

View file

@ -102,10 +102,10 @@
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
{{t('cover-image-description')}}
<!-- {{t('cover-image-description-extra')}}-->
{{t('cover-image-description-extra')}}
</p>
<!-- <button class="btn btn-primary mb-2 w-100" (click)="downloadCover()" [disabled]="fetchDisabled">{{t('download-coversdb')}}</button>-->
<button class="btn btn-primary mb-2 w-100" (click)="downloadCover()" [disabled]="fetchDisabled">{{t('download-coversdb')}}</button>
<app-cover-image-chooser [(imageUrls)]="imageUrls"
(imageUrlsChange)="handleUploadByUrl($event)"

View file

@ -96,7 +96,7 @@
</div>
<!-- External Series including Author -->
@if (accountService.hasValidLicense$ | async) {
@if (licenseService.hasValidLicense$ | async) {
<div class="row mt-2">
</div>

View file

@ -40,6 +40,7 @@ import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component
import {ThemeService} from "../_services/theme.service";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service";
@Component({
selector: 'app-person-detail',
@ -74,6 +75,7 @@ export class PersonDetailComponent {
private readonly modalService = inject(NgbModal);
protected readonly imageService = inject(ImageService);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
private readonly themeService = inject(ThemeService);
private readonly toastr = inject(ToastrService);

View file

@ -87,7 +87,7 @@
</div>
}
@if ((accountService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
@if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
<div class="col-auto ms-2">
<button class="btn btn-actions" (click)="toggleScrobbling($event)" [ngbTooltip]="t('scrobbling-tooltip')">
<i class="fa-solid fa-tower-{{(isScrobbling) ? 'broadcast' : 'observation'}}" aria-hidden="true"></i>

View file

@ -120,6 +120,8 @@ import {CollectionTagService} from "../../../_services/collection-tag.service";
import {UserCollection} from "../../../_models/collection-tag";
import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {LicenseService} from "../../../_services/license.service";
enum TabID {
@ -164,6 +166,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
private readonly modalService = inject(NgbModal);
private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly libraryService = inject(LibraryService);
private readonly titleService = inject(Title);
@ -585,6 +588,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (this.downloadInProgress) return;
this.downloadSeries();
break;
case Action.Match:
this.actionService.matchSeries(this.series, (refreshNeeded) => {
if (refreshNeeded) {
this.loadSeries(this.series.id, refreshNeeded);
}
});
break;
case Action.SendTo:
{
const chapterIds = [...this.volumes.map(v => v.chapters.map(c => c.id)).flat(), ...this.specials.map(c => c.id)]

View file

@ -3,7 +3,7 @@
<ng-content></ng-content>
@if (subtitle) {
<div class="description text-muted" [innerHTML]="subtitle | safeHtml"></div>
<div class="description text-muted mt-1" [innerHTML]="subtitle | safeHtml"></div>
}
</div>
</ng-container>

View file

@ -4,7 +4,7 @@ import {
Component,
ContentChild, ElementRef, EventEmitter, HostListener,
inject,
Input, Output,
Input, OnChanges, Output, SimpleChange, SimpleChanges,
TemplateRef
} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@ -25,7 +25,7 @@ import {AbstractControl, FormControl} from "@angular/forms";
styleUrl: './setting-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SettingItemComponent {
export class SettingItemComponent implements OnChanges {
private readonly cdRef = inject(ChangeDetectorRef);
@ -85,6 +85,23 @@ export class SettingItemComponent {
.subscribe();
}
ngOnChanges(changes: SimpleChanges) {
if (changes.hasOwnProperty('isEditMode')) {
const change = changes.isEditMode as SimpleChange;
if (change.isFirstChange()) return;
if (!this.toggleOnViewClick) return;
if (!this.canEdit) return;
if (this.control != null && this.control.invalid) return;
console.log('isEditMode', this.isEditMode, 'currentValue', change.currentValue);
this.isEditMode = change.currentValue;
//this.editMode.emit(this.isEditMode);
this.cdRef.markForCheck();
}
}
toggleEditMode() {
if (!this.toggleOnViewClick) return;

View file

@ -6,7 +6,7 @@
</h2>
</app-side-nav-companion-bar>
<div class="row col-me-4 pb-3">
@if (accountService.currentUser$ | async; as user) {
@if (accountService.hasAdminRole(user)) {
@defer (when fragment === SettingsTabId.General; prefetch on idle) {
@ -16,7 +16,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Email; prefetch on idle) {
@if (fragment === SettingsTabId.Email) {
<div class="col-xxl-6 col-12">
@ -24,7 +24,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Media; prefetch on idle) {
@if (fragment === SettingsTabId.Media) {
<div class="col-xxl-6 col-12">
@ -32,7 +32,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Users; prefetch on idle) {
@if (fragment === SettingsTabId.Users) {
<div class="scale col-md-12">
@ -40,7 +40,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Libraries; prefetch on idle) {
@if (fragment === SettingsTabId.Libraries) {
<div class="scale col-md-12">
@ -48,7 +48,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) {
@if (fragment === SettingsTabId.MediaIssues) {
<div class="scale col-md-12">
@ -56,7 +56,15 @@
</div>
}
}
@defer (when fragment === SettingsTabId.EmailHistory; prefetch on idle) {
@if (fragment === SettingsTabId.EmailHistory) {
<div class="scale col-md-12">
<app-email-history></app-email-history>
</div>
}
}
@defer (when fragment === SettingsTabId.System; prefetch on idle) {
@if (fragment === SettingsTabId.System) {
<div class="scale col-md-12">
@ -64,7 +72,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Statistics; prefetch on idle) {
@if (fragment === SettingsTabId.Statistics) {
<div class="scale col-md-12">
@ -72,7 +80,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Tasks; prefetch on idle) {
@if (fragment === SettingsTabId.Tasks) {
<div class="scale col-md-12">
@ -80,17 +88,49 @@
</div>
}
}
@defer (when fragment === SettingsTabId.KavitaPlus; prefetch on idle) {
@if (fragment === SettingsTabId.KavitaPlus) {
@defer (when fragment === SettingsTabId.KavitaPlusLicense; prefetch on idle) {
@if (fragment === SettingsTabId.KavitaPlusLicense) {
<div class="scale col-md-12">
<app-manage-kavitaplus></app-manage-kavitaplus>
<app-license></app-license>
</div>
}
}
@defer (when fragment === SettingsTabId.MatchedMetadata; prefetch on idle) {
@if (fragment === SettingsTabId.MatchedMetadata) {
<div class="scale col-md-12">
<app-manage-matched-metadata></app-manage-matched-metadata>
</div>
}
}
@defer (when fragment === SettingsTabId.ManageUserTokens; prefetch on idle) {
@if (fragment === SettingsTabId.ManageUserTokens) {
<div class="scale col-md-12">
<app-manage-user-tokens></app-manage-user-tokens>
</div>
}
}
}
@defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) {
<div class="scale col-md-12">
<app-manage-scrobling></app-manage-scrobling>
</div>
}
}
@defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) {
<div class="scale col-md-12">
<app-user-holds></app-user-holds>
</div>
}
}
@defer (when fragment === SettingsTabId.Account; prefetch on idle) {
@if (fragment === SettingsTabId.Account) {
<div class="col-xxl-6 col-12">
@ -104,7 +144,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Preferences; prefetch on idle) {
@if (fragment === SettingsTabId.Preferences) {
<div class="col-xxl-6 col-12">
@ -112,7 +152,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Customize; prefetch on idle) {
@if (fragment === SettingsTabId.Customize) {
<div class="scale col-md-12">
@ -120,7 +160,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Clients; prefetch on idle) {
@if (fragment === SettingsTabId.Clients) {
<div class="col-xxl-6 col-12">
@ -128,7 +168,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Theme; prefetch on idle) {
@if (fragment === SettingsTabId.Theme) {
<div class="scale col-md-12">
@ -136,7 +176,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Devices; prefetch on idle) {
@if (fragment === SettingsTabId.Devices) {
<div class="scale col-md-12">
@ -144,7 +184,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.UserStats; prefetch on idle) {
@if (fragment === SettingsTabId.UserStats) {
<div class="scale col-md-12">
@ -152,7 +192,7 @@
</div>
}
}
@defer (when fragment === SettingsTabId.CBLImport; prefetch on idle) {
@if (fragment === SettingsTabId.CBLImport) {
<div class="scale col-md-12">
@ -160,15 +200,8 @@
</div>
}
}
@defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) {
<div class="scale col-md-12">
<app-manage-scrobling></app-manage-scrobling>
</div>
}
}
@defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) {
<div class="scale col-md-12">

View file

@ -12,17 +12,12 @@ import {
import {
ManageUserPreferencesComponent
} from "../../../user-settings/manga-user-preferences/manage-user-preferences.component";
import {NgbNav, NgbNavContent, NgbNavLinkBase} from "@ng-bootstrap/ng-bootstrap";
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ActivatedRoute, Router} from "@angular/router";
import {
SideNavCompanionBarComponent
} from "../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {ThemeManagerComponent} from "../../../user-settings/theme-manager/theme-manager.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component";
import {
UserScrobbleHistoryComponent
} from "../../../_single-module/user-scrobble-history/user-scrobble-history.component";
import {UserStatsComponent} from "../../../statistics/_components/user-stats/user-stats.component";
import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component";
import {AsyncPipe} from "@angular/common";
@ -40,10 +35,6 @@ import {ServerStatsComponent} from "../../../statistics/_components/server-stats
import {SettingFragmentPipe} from "../../../_pipes/setting-fragment.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {tap} from "rxjs";
import {
KavitaplusMetadataBreakdownStatsComponent
} from "../../../statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component";
import {ManageKavitaplusComponent} from "../../../admin/manage-kavitaplus/manage-kavitaplus.component";
import {ManageScrobblingComponent} from "../../../admin/manage-scrobling/manage-scrobbling.component";
import {ManageMediaIssuesComponent} from "../../../admin/manage-media-issues/manage-media-issues.component";
import {
@ -53,6 +44,11 @@ import {
ImportMalCollectionComponent
} from "../../../collections/_components/import-mal-collection/import-mal-collection.component";
import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/import-cbl.component";
import {LicenseService} from "../../../_services/license.service";
import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-metadata/manage-matched-metadata.component";
import {ManageUserTokensComponent} from "../../../admin/manage-user-tokens/manage-user-tokens.component";
import {EmailHistoryComponent} from "../../../admin/email-history/email-history.component";
import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component";
@Component({
selector: 'app-settings',
@ -65,15 +61,9 @@ import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/i
ManageOpdsComponent,
ManageScrobblingProvidersComponent,
ManageUserPreferencesComponent,
NgbNav,
NgbNavContent,
NgbNavLinkBase,
RouterLink,
SideNavCompanionBarComponent,
ThemeManagerComponent,
TranslocoDirective,
ScrobblingHoldsComponent,
UserScrobbleHistoryComponent,
UserStatsComponent,
AsyncPipe,
LicenseComponent,
@ -86,13 +76,15 @@ import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/i
ManageUsersComponent,
ServerStatsComponent,
SettingFragmentPipe,
KavitaplusMetadataBreakdownStatsComponent,
ManageKavitaplusComponent,
ManageScrobblingComponent,
ManageMediaIssuesComponent,
ManageCustomizationComponent,
ImportMalCollectionComponent,
ImportCblComponent
ImportCblComponent,
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
@ -105,6 +97,7 @@ export class SettingsComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
protected readonly SettingsTabId = SettingsTabId;
protected readonly WikiLink = WikiLink;
@ -128,7 +121,7 @@ export class SettingsComponent {
this.cdRef.markForCheck();
}), takeUntilDestroyed(this.destroyRef)).subscribe();
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res) {
this.hasActiveLicense = true;
this.cdRef.markForCheck();

View file

@ -1,7 +1,7 @@
import { ConfirmButton } from './confirm-button';
export class ConfirmConfig {
_type: 'confirm' | 'alert' = 'confirm';
_type: 'confirm' | 'alert' | 'info' = 'confirm';
header: string = 'Confirm';
content: string = '';
buttons: Array<ConfirmButton> = [];

View file

@ -1,14 +1,18 @@
<ng-container *transloco="let t">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{config.header}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('common.close')" (click)="close()" *ngIf="!config.disableEscape"></button>
<h4 class="modal-title" id="modal-basic-title">{{config.header | confirmTranslate}}</h4>
@if (!config.disableEscape) {
<button type="button" class="btn-close" [attr.aria-label]="t('common.close')" (click)="close()"></button>
}
</div>
<div class="modal-body" style="overflow-x: auto" [innerHtml]="config.content | safeHtml">
<div class="modal-body" style="overflow-x: auto" [innerHtml]="(config.content | confirmTranslate)! | safeHtml">
</div>
<div class="modal-footer">
<div *ngFor="let btn of config.buttons">
<button type="button" class="btn btn-{{btn.type}}" (click)="clickButton(btn)">{{btn.text}}</button>
</div>
@for(btn of config.buttons; track btn) {
<div>
<button type="button" class="btn btn-{{btn.type}}" (click)="clickButton(btn)">{{btn.text | confirmTranslate}}</button>
</div>
}
</div>
</ng-container>

View file

@ -1,23 +1,24 @@
import { Component, OnInit } from '@angular/core';
import {Component, inject, OnInit} from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmButton } from './_models/confirm-button';
import { ConfirmConfig } from './_models/confirm-config';
import {CommonModule} from "@angular/common";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {TranslocoDirective} from "@jsverse/transloco";
import {ConfirmTranslatePipe} from "../../_pipes/confirm-translate.pipe";
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective],
imports: [SafeHtmlPipe, TranslocoDirective, ConfirmTranslatePipe],
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss']
})
export class ConfirmDialogComponent implements OnInit {
config!: ConfirmConfig;
protected readonly modal = inject(NgbActiveModal);
constructor(public modal: NgbActiveModal) {}
config!: ConfirmConfig;
ngOnInit(): void {
if (this.config) {

View file

@ -4,6 +4,8 @@ import { take } from 'rxjs/operators';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { ConfirmConfig } from './confirm-dialog/_models/confirm-config';
import {translate} from "@jsverse/transloco";
import {ConfirmButton} from "./confirm-dialog/_models/confirm-button";
@Injectable({
providedIn: 'root'
@ -12,15 +14,25 @@ export class ConfirmService {
defaultConfirm = new ConfirmConfig();
defaultAlert = new ConfirmConfig();
defaultInfo = new ConfirmConfig();
constructor(private modalService: NgbModal) {
this.defaultConfirm.buttons.push({text: 'Cancel', type: 'secondary'});
this.defaultConfirm.buttons.push({text: 'Confirm', type: 'primary'});
this.defaultConfirm.buttons = [
{text: 'confirm.cancel', type: 'secondary'},
{text: 'confirm.confirm', type: 'primary'},
];
this.defaultAlert._type = 'alert';
this.defaultAlert.header = 'Alert';
this.defaultAlert.buttons.push({text: 'Ok', type: 'primary'});
this.defaultAlert.header = 'confirm.alert';
this.defaultAlert.buttons = [
{text: 'confirm.ok', type: 'primary'}
];
this.defaultInfo.buttons = [
{text: 'confirm.ok', type: 'primary'}
];
this.defaultInfo.header = 'confirm.info';
this.defaultInfo._type = 'info';
}
public async confirm(content?: string, config?: ConfirmConfig): Promise<boolean> {
@ -33,7 +45,7 @@ export class ConfirmService {
if (content !== undefined && config === undefined) {
config = this.defaultConfirm;
config.header = translate('confirm.confirm');
config.header = 'confirm.confirm';
config.content = content;
}
if (content !== undefined && content !== '' && config!.content === '') {
@ -52,6 +64,33 @@ export class ConfirmService {
}
public async info(content: string, header?: string, config?: ConfirmConfig): Promise<boolean> {
return new Promise((resolve, reject) => {
if (content === undefined && config === undefined) {
console.error('Alert must have either text or a config object passed');
return reject(false);
}
if (content !== undefined && config === undefined) {
config = this.defaultInfo;
config.content = content;
if (header != undefined) {
config.header = header;
}
}
const modalRef = this.modalService.open(ConfirmDialogComponent, {size: "lg", fullscreen: "md"});
modalRef.componentInstance.config = config;
modalRef.closed.pipe(take(1)).subscribe(result => {
return resolve(result);
});
modalRef.dismissed.pipe(take(1)).subscribe(() => {
return resolve(false);
});
});
}
public async alert(content?: string, config?: ConfirmConfig): Promise<boolean> {
return new Promise((resolve, reject) => {
if (content === undefined && config === undefined) {
@ -61,7 +100,7 @@ export class ConfirmService {
if (content !== undefined && config === undefined) {
config = this.defaultAlert;
config.header = translate('confirm.alert');
config.header = 'confirm.alert';
config.content = content;
}
@ -73,6 +112,6 @@ export class ConfirmService {
modalRef.dismissed.pipe(take(1)).subscribe(() => {
return resolve(false);
});
})
});
}
}

View file

@ -2,7 +2,7 @@
@if (accountService.currentUser$ | async; as user) {
<div class="side-nav-container" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false,
'no-donate': (accountService.hasValidLicense$ | async) === true}">
'no-donate': (licenseService.hasValidLicense$ | async) === true}">
<div class="side-nav">
@ -88,8 +88,8 @@
}
<div class="sidenav-bottom" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false || (accountService.hasValidLicense$ | async) === true}">
@if ((accountService.hasValidLicense$ | async) === false) {
'hidden': (navService.sideNavVisibility$ | async) === false || (licenseService.hasValidLicense$ | async) === true}">
@if ((licenseService.hasValidLicense$ | async) === false) {
<app-side-nav-item [ngClass]="'donate'" icon="fa-heart"
[ngbTooltip]="t('donate-tooltip')"
[link]="WikiLink.Donation"

View file

@ -30,6 +30,7 @@ import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {WikiLink} from "../../../_models/wiki";
import {SettingsTabId} from "../../preference-nav/preference-nav.component";
import {LicenseService} from "../../../_services/license.service";
@Component({
selector: 'app-side-nav',
@ -49,6 +50,7 @@ export class SideNavComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly imageService = inject(ImageService);
public readonly accountService = inject(AccountService);
public readonly licenseService = inject(LicenseService);
private readonly destroyRef = inject(DestroyRef);
private readonly actionFactoryService = inject(ActionFactoryService);

View file

@ -4,7 +4,7 @@
@if((navService.sideNavCollapsed$ | async) === false) {
<div class="preference side-nav-container" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false,
'no-donate': (accountService.hasValidLicense$ | async) === true}">
'no-donate': (licenseService.hasValidLicense$ | async) === true}">
<div class="side-nav">
@for(section of sections; track section.title + section.children.length; let idx = $index;) {

View file

@ -2,9 +2,9 @@ import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, De
import {TranslocoDirective} from "@jsverse/transloco";
import {AsyncPipe, DOCUMENT, NgClass} from "@angular/common";
import {NavService} from "../../_services/nav.service";
import {AccountService, allRoles, Role} from "../../_services/account.service";
import {AccountService, Role} from "../../_services/account.service";
import {SideNavItemComponent} from "../_components/side-nav-item/side-nav-item.component";
import {ActivatedRoute, NavigationEnd, Router, RouterLink} from "@angular/router";
import {ActivatedRoute, NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingFragmentPipe} from "../../_pipes/setting-fragment.pipe";
import {map, Observable, of, shareReplay, switchMap, take, tap} from "rxjs";
@ -13,6 +13,9 @@ import {ScrobblingService} from "../../_services/scrobbling.service";
import {User} from "../../_models/user";
import {filter} from "rxjs/operators";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {LicenseService} from "../../_services/license.service";
import {ManageService} from "../../_services/manage.service";
import {MatchStateOption} from "../../_models/kavitaplus/match-state-option";
export enum SettingsTabId {
@ -26,10 +29,13 @@ export enum SettingsTabId {
Tasks = 'admin-tasks',
Statistics = 'admin-statistics',
MediaIssues = 'admin-media-issues',
EmailHistory = 'admin-email-history',
// Kavita+
KavitaPlus = 'admin-kavitaplus',
KavitaPlusLicense = 'admin-kavitaplus',
MALStackImport = 'mal-stack-import',
MatchedMetadata = 'admin-matched-metadata',
ManageUserTokens = 'admin-manage-tokens',
// Non-Admin
Account = 'account',
@ -39,6 +45,7 @@ export enum SettingsTabId {
Devices = 'devices',
UserStats = 'user-stats',
Scrobbling = 'scrobbling',
ScrobblingHolds = 'scrobble-holds',
Customize = 'customize',
CBLImport = 'cbl-import'
}
@ -84,12 +91,14 @@ export class PreferenceNavComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
protected readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly serverService = inject(ServerService);
private readonly scrobbleService = inject(ScrobblingService);
private readonly router = inject(Router);
protected readonly utilityService = inject(UtilityService);
private readonly manageService = inject(ManageService);
private readonly document = inject(DOCUMENT);
hasActiveLicense = false;
@ -147,12 +156,14 @@ export class PreferenceNavComponent implements AfterViewInit {
}
})
)),
new SideNavItem(SettingsTabId.EmailHistory, [Role.Admin]),
]
},
{
title: 'kavitaplus-section-title',
children: [
new SideNavItem(SettingsTabId.KavitaPlus, [Role.Admin]),
new SideNavItem(SettingsTabId.KavitaPlusLicense, [Role.Admin])
// All other sections added dynamically
]
}
];
@ -170,6 +181,40 @@ export class PreferenceNavComponent implements AfterViewInit {
}),
);
private readonly matchedMetadataBadgeCount$ = this.accountService.currentUser$.pipe(
take(1),
switchMap(user => {
if (!user || !this.accountService.hasAdminRole(user)) {
// If no user or user does not have the admin role, return an observable of -1
return of(-1);
} else {
return this.manageService.getAllKavitaPlusSeries({
matchStateOption: MatchStateOption.Error,
searchTerm: ''
}).pipe(
takeUntilDestroyed(this.destroyRef),
map(d => d.length),
shareReplay({bufferSize: 1, refCount: true})
);
}
})
);
private readonly scrobblingErrorBadgeCount$ = this.accountService.currentUser$.pipe(
take(1),
switchMap(user => {
if (!user || !this.accountService.hasAdminRole(user)) {
// If no user or user does not have the admin role, return an observable of -1
return of(-1);
} else {
return this.scrobbleService.getScrobbleErrors().pipe(
takeUntilDestroyed(this.destroyRef),
map(d => d.length),
shareReplay({bufferSize: 1, refCount: true})
);
}
})
);
constructor() {
this.collapseSideNavOnMobileNav$.subscribe();
@ -179,33 +224,24 @@ export class PreferenceNavComponent implements AfterViewInit {
this.navService.collapseSideNav(true);
}
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
this.hasActiveLicense = res;
if (res) {
this.hasActiveLicense = true;
if (this.hasActiveLicense) {
if (this.sections[4].children.length === 1) {
this.sections[4].children.push(new SideNavItem(SettingsTabId.Scrobbling, [],
this.accountService.currentUser$.pipe(
take(1),
switchMap(user => {
if (!user || !this.accountService.hasAdminRole(user)) {
// If no user or user does not have the admin role, return an observable of -1
return of(-1);
} else {
return this.scrobbleService.getScrobbleErrors().pipe(
takeUntilDestroyed(this.destroyRef),
map(d => d.length),
shareReplay({ bufferSize: 1, refCount: true })
);
}
})
))
);
}
const kavitaPlusSection = this.sections[4];
if (kavitaPlusSection.children.length === 1) {
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.MatchedMetadata, [Role.Admin],
this.matchedMetadataBadgeCount$
));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin]));
if (this.sections[2].children.length === 1) {
this.sections[2].children.push(new SideNavItem(SettingsTabId.MALStackImport, []));
}
// Scrobbling History needs to be per-user and allow admin to view all
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, []));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Scrobbling, [], this.scrobblingErrorBadgeCount$)
);
}
if (this.sections[2].children.length === 1) {
this.sections[2].children.push(new SideNavItem(SettingsTabId.MALStackImport, []));
}
this.scrollToActiveItem();

Some files were not shown because too many files have changed in this diff Show more