A ton of random bugs and polish (#3668)

This commit is contained in:
Joe Milazzo 2025-03-23 17:06:20 -05:00 committed by GitHub
parent b45d92ea5c
commit de651215f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
144 changed files with 852 additions and 848 deletions

View file

@ -0,0 +1,62 @@
export const isSafari = [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod'
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document);
/**
* Represents a Version for a browser
*/
export class Version {
major: number;
minor: number;
patch: number;
constructor(major: number, minor: number, patch: number) {
this.major = major;
this.minor = minor;
this.patch = patch;
}
isLessThan(other: Version): boolean {
if (this.major < other.major) return true;
if (this.major > other.major) return false;
if (this.minor < other.minor) return true;
if (this.minor > other.minor) return false;
return this.patch < other.patch;
}
isGreaterThan(other: Version): boolean {
if (this.major > other.major) return true;
if (this.major < other.major) return false;
if (this.minor > other.minor) return true;
if (this.minor < other.minor) return false;
return this.patch > other.patch;
}
isEqualTo(other: Version): boolean {
return (
this.major === other.major &&
this.minor === other.minor &&
this.patch === other.patch
);
}
}
export const getIosVersion = () => {
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
if (match) {
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
const patch = parseInt(match[3] || '0', 10);
return new Version(major, minor, patch);
}
return null;
}

View file

@ -1,5 +1,4 @@
import {FileTypeGroup} from "./file-type-group.enum";
import {IHasCover} from "../common/i-has-cover";
export enum LibraryType {
Manga = 0,
@ -10,6 +9,8 @@ export enum LibraryType {
ComicVine = 5
}
export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images];
export interface Library {
id: number;
name: string;

View file

@ -11,7 +11,7 @@ import {TranslocoService} from "@jsverse/transloco";
})
export class LibraryTypePipe implements PipeTransform {
translocoService = inject(TranslocoService);
private readonly translocoService = inject(TranslocoService);
transform(libraryType: LibraryType): string {
switch (libraryType) {
case LibraryType.Book:

View file

@ -23,6 +23,7 @@ import {Volume} from "../_models/volume";
import {UtilityService} from "../shared/_services/utility.service";
import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
import {getIosVersion, isSafari, Version} from "../_helpers/browser";
export const CHAPTER_ID_DOESNT_EXIST = -1;
@ -46,7 +47,8 @@ export class ReaderService {
// Override background color for reader and restore it onDestroy
private originalBodyColor!: string;
private noSleep = new NoSleep();
private noSleep: NoSleep = new NoSleep();
constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
@ -56,17 +58,18 @@ export class ReaderService {
});
}
enableWakeLock(element?: Element | Document) {
// Enable wake lock.
// (must be wrapped in a user input event handler e.g. a mouse or touch handler)
if (!element) element = this.document;
const enableNoSleepHandler = () => {
const enableNoSleepHandler = async () => {
element!.removeEventListener('click', enableNoSleepHandler, false);
element!.removeEventListener('touchmove', enableNoSleepHandler, false);
element!.removeEventListener('mousemove', enableNoSleepHandler, false);
this.noSleep!.enable();
await this.noSleep.enable();
};
// Enable wake lock.

View file

@ -41,10 +41,6 @@ export class ServerService {
return this.http.post(this.baseUrl + 'server/backup-db', {});
}
analyzeFiles() {
return this.http.post(this.baseUrl + 'server/analyze-files', {});
}
syncThemes() {
return this.http.post(this.baseUrl + 'server/sync-themes', {});
}
@ -58,10 +54,6 @@ export class ServerService {
.pipe(map(r => parseInt(r, 10)));
}
checkForUpdates() {
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-for-updates', {});
}
getChangelog(count: number = 0) {
return this.http.get<UpdateVersionEvent[]>(this.baseUrl + 'server/changelog?count=' + count, {});
}

View file

@ -41,7 +41,7 @@
<div class="mb-3 ms-1">
<h4 class="header">{{t('genres-title')}}</h4>
<div class="ms-3">
<app-badge-expander [includeComma]="true" [items]="genres" [itemsTillExpander]="3">
<app-badge-expander [includeComma]="true" [items]="genres" [itemsTillExpander]="3" [defaultExpanded]="true">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Genres, item.id)">{{item.title}}</a>
</ng-template>
@ -52,7 +52,7 @@
<div class="mb-3 ms-1">
<h4 class="header">{{t('tags-title')}}</h4>
<div class="ms-3">
<app-badge-expander [includeComma]="true" [items]="tags" [itemsTillExpander]="3">
<app-badge-expander [includeComma]="true" [items]="tags" [itemsTillExpander]="3" [defaultExpanded]="true">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Tags, item.id)">{{item.title}}</a>
</ng-template>

View file

@ -37,7 +37,7 @@ import {DownloadService} from "../../shared/_services/download.service";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {forkJoin, Observable, of, tap} from "rxjs";
import {map} from "rxjs/operators";
import {map, switchMap} from "rxjs/operators";
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
@ -212,11 +212,13 @@ export class EditChapterModalComponent implements OnInit {
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
this.editForm.addControl('coverImageLocked', new FormControl(this.chapter.coverImageLocked, []));
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
this.validLanguages = validLanguages;
this.setupLanguageTypeahead();
this.cdRef.markForCheck();
});
this.metadataService.getAllValidLanguages().pipe(
tap(validLanguages => {
this.validLanguages = validLanguages;
this.cdRef.markForCheck();
}),
switchMap(_ => this.setupLanguageTypeahead())
).subscribe();
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;

View file

@ -90,7 +90,7 @@
<div class="row g-0 mt-2">
@if(settingsForm.get('enableSsl'); as formControl) {
<app-setting-switch [title]="t('enable-ssl-label')">
<app-setting-switch labelId="setting-enable-ssl" [title]="t('enable-ssl-label')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableSsl">

View file

@ -1,77 +1,72 @@
<ng-container *transloco="let t; read: 'manage-system'">
<div class="container-fluid">
@if (serverInfo) {
<div class="mb-3">
<h3>{{t('title')}}</h3>
<div class="row">
<div class="col-4">
<div>{{t('version-title')}}</div>
<div>{{serverInfo.kavitaVersion}}</div>
</div>
<div class="col-4">
<div>{{t('installId-title')}}</div>
<div>{{serverInfo.installId}}</div>
</div>
</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>
}
@if (serverInfo) {
<div class="mb-3">
<h3>{{t('more-info-title')}}</h3>
<h3>{{t('title')}}</h3>
<div class="row">
<div class="col-4">{{t('home-page-title')}}</div>
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
<div class="col-4">
<div>{{t('version-title')}}</div>
<div>{{serverInfo.kavitaVersion}}</div>
</div>
<div class="col-4">
<div>{{t('installId-title')}}</div>
<div>{{serverInfo.installId}}</div>
</div>
</div>
<div class="row">
<div class="col-4">{{t('wiki-title')}}</div>
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
</div>
<div class="row">
<div class="col-4">{{t('discord-title')}}</div>
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
</div>
<div class="row">
<div class="col-4">{{t('donations-title')}}</div>
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
</div>
<div class="row">
<div class="col-4">{{t('source-title')}}</div>
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
</div>
<div class="row">
<div class="col-4">{{t('localization-title')}}</div>
<div class="col"><a href="https://hosted.weblate.org/engage/kavita/" target="_blank" rel="noopener noreferrer">Weblate</a><br/></div>
</div>
<div class="row">
<div class="col-4">{{t('feature-request-title')}}</div>
<div class="col"><a href="https://github.com/Kareadita/Kavita/discussions/2529" target="_blank" rel="noopener noreferrer">https://github.com/Kareadita/Kavita/discussions/</a><br/></div>
<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('updates-title')}}</h3>
<app-changelog></app-changelog>
<div class="mb-3">
<h3>{{t('more-info-title')}}</h3>
<div class="row">
<div class="col-4">{{t('home-page-title')}}</div>
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
</div>
<div class="row">
<div class="col-4">{{t('wiki-title')}}</div>
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
</div>
<div class="row">
<div class="col-4">{{t('discord-title')}}</div>
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
</div>
<div class="row">
<div class="col-4">{{t('donations-title')}}</div>
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
</div>
<div class="row">
<div class="col-4">{{t('source-title')}}</div>
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
</div>
<div class="row">
<div class="col-4">{{t('localization-title')}}</div>
<div class="col"><a href="https://hosted.weblate.org/engage/kavita/" target="_blank" rel="noopener noreferrer">Weblate</a><br/></div>
</div>
<div class="row">
<div class="col-4">{{t('feature-request-title')}}</div>
<div class="col"><a href="https://github.com/Kareadita/Kavita/discussions/2529" target="_blank" rel="noopener noreferrer">https://github.com/Kareadita/Kavita/discussions/</a><br/></div>
</div>
</div>
<div class="setting-section-break"></div>
<div class="mb-3">
<h3>{{t('updates-title')}}</h3>
<app-changelog></app-changelog>
</div>
</ng-container>

View file

@ -1,11 +1,9 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {ServerService} from 'src/app/_services/server.service';
import {ServerInfoSlim} from '../_models/server-info';
import {DatePipe, NgIf} from '@angular/common';
import {DatePipe} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {ChangelogComponent} from "../../announcements/_components/changelog/changelog.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
@Component({
selector: 'app-manage-system',
@ -13,7 +11,7 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
styleUrls: ['./manage-system.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, TranslocoDirective, ChangelogComponent, DefaultDatePipe, DefaultValuePipe, DatePipe]
imports: [TranslocoDirective, ChangelogComponent, DatePipe]
})
export class ManageSystemComponent implements OnInit {

View file

@ -1,194 +1,192 @@
<ng-container *transloco="let t; read: 'manage-tasks-settings'">
<div class="container-fluid">
@if (serverSettings) {
<form [formGroup]="settingsForm">
@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)}}
<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>
}
</ng-template>
<ng-template #edit>
</select>
<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>
@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>
</div>
}
</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-scan" formControlName="taskScanCustom"
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
aria-describedby="task-scan-validations">
<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 (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 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>
}
</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-backup" formControlName="taskBackupCustom"
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.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-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>
}
</select>
</div>
}
</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>
<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>
}
</ng-template>
</app-setting-item>
}
</div>
</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">
<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>
@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>
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
@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">
<div class="setting-section-break"></div>
@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>
<h4>{{t('adhoc-tasks-title')}}</h4>
<div class="setting-section-break"></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>
}
<h4>{{t('adhoc-tasks-title')}}</h4>
<div class="setting-section-break"></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>
}
<h4>{{t('recurring-tasks-title')}}</h4>
<ngx-datatable
class="bootstrap"
[rows]="recurringTasks$ | async"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<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 prop="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 prop="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 prop="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 prop="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 prop="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>
<ngx-datatable-column prop="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>
}
</ng-container>

View file

@ -106,13 +106,6 @@ export class ManageTasksSettingsComponent implements OnInit {
api: defer(() => of(this.downloadService.download('logs', undefined))),
successMessage: ''
},
// TODO: Remove this in v0.9. Users should have all updated by then
{
name: 'analyze-files-task',
description: 'analyze-files-task-desc',
api: this.serverService.analyzeFiles(),
successMessage: 'analyze-files-task-success'
},
{
name: 'sync-themes-task',
description: 'sync-themes-task-desc',

View file

@ -1,52 +1,49 @@
<ng-container *transloco="let t; read:'manage-user-tokens'">
<div class="container-fluid">
<p>{{t('description')}}</p>
<p>{{t('description')}}</p>
<ngx-datatable
class="bootstrap"
[rows]="users"
[columnMode]="ColumnMode.force"
rowHeight="auto"
[footerHeight]="50"
>
<ngx-datatable
class="bootstrap"
[rows]="users"
[columnMode]="ColumnMode.force"
rowHeight="auto"
[footerHeight]="50"
>
<ngx-datatable-column prop="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 prop="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 prop="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 prop="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 prop="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>
<ngx-datatable-column prop="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>
</ng-container>

View file

@ -18,7 +18,7 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {forkJoin, Observable, of, tap} from 'rxjs';
import { map } from 'rxjs/operators';
import { map, switchMap } from 'rxjs/operators';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
@ -238,10 +238,7 @@ export class EditSeriesModalComponent implements OnInit {
this.cdRef.markForCheck();
});
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
this.validLanguages = validLanguages;
this.cdRef.markForCheck();
});
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
if (metadata) {
@ -437,30 +434,41 @@ export class EditSeriesModalComponent implements OnInit {
}
setupLanguageTypeahead() {
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
return of(true);
return this.metadataService.getAllValidLanguages()
.pipe(
tap(validLanguages => {
this.validLanguages = validLanguages;
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
this.cdRef.markForCheck();
}),
switchMap(_ => of(true))
);
}
setupPersonTypeahead() {

View file

@ -1,10 +1,6 @@
<ng-container *transloco="let t; read: 'import-mal-collection-modal'">
<p>{{t('description')}}</p>
@if (stacks.length === 0) {
<p>{{t('nothing-found')}}</p>
}
<ul>
@for(stack of stacks; track stack.url) {
<li class="mb-2">
@ -21,6 +17,8 @@
} @empty {
@if (isLoading) {
<app-loading [loading]="isLoading"></app-loading>
} @else {
<p>{{t('nothing-found')}}</p>
}
}
</ul>

View file

@ -22,6 +22,7 @@ import { MangaReaderService } from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
import { NgClass, AsyncPipe } from '@angular/common';
import {isSafari} from "../../../_helpers/browser";
const ValidSplits = [PageSplitOption.SplitLeftToRight, PageSplitOption.SplitRightToLeft];
@ -35,13 +36,21 @@ const ValidSplits = [PageSplitOption.SplitLeftToRight, PageSplitOption.SplitRigh
})
export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRenderer {
protected readonly isSafari = isSafari;
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly mangaReaderService = inject(MangaReaderService);
private readonly readerService = inject(ReaderService);
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;
@Input({required: true}) bookmark$!: Observable<number>;
@Input({required: true}) showClickOverlay$!: Observable<boolean>;
@Input() imageFit$!: Observable<FITTING_OPTION>;
@Output() imageHeight: EventEmitter<number> = new EventEmitter<number>();
private readonly destroyRef = inject(DestroyRef);
@ViewChild('content') canvas: ElementRef | undefined;
private ctx!: CanvasRenderingContext2D;
@ -67,7 +76,6 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: MangaReaderService, private readerService: ReaderService) { }
ngOnInit(): void {
this.readerSettings$.pipe(takeUntilDestroyed(this.destroyRef), tap((value: ReaderSetting) => {
@ -250,21 +258,11 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
setCanvasSize() {
if (this.canvasImage == null) return;
if (!this.ctx || !this.canvas) { return; }
const isSafari = [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod'
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document);
const canvasLimit = isSafari ? 16_777_216 : 124_992_400;
const canvasLimit = this.isSafari ? 16_777_216 : 124_992_400;
const needsScaling = this.canvasImage.width * this.canvasImage.height > canvasLimit;
if (needsScaling) {
this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384;
this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384;
this.canvas.nativeElement.width = this.isSafari ? 4_096 : 16_384;
this.canvas.nativeElement.height = this.isSafari ? 4_096 : 16_384;
} else {
this.canvas.nativeElement.width = this.canvasImage.width;
this.canvas.nativeElement.height = this.canvasImage.height;

View file

@ -1,11 +1,11 @@
<ng-container *transloco="let t; read: 'metadata-filter'">
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
<ng-container *ngIf="utilityService.getActiveBreakpoint() as activeBreakpoint">
<div *ngIf="activeBreakpoint >= Breakpoint.Tablet; else mobileView" [@inOutAnimation]>
<ng-container [ngTemplateOutlet]="filterSection" [ngTemplateOutletContext]="{isOpen: isOpen}"></ng-container>
</div>
<ng-template #mobileView>
@if (toggleService.toggleState$ | async; as isOpen) {
@if (utilityService.getActiveBreakpoint(); as activeBreakpoint) {
@if (activeBreakpoint >= Breakpoint.Tablet) {
<div [@inOutAnimation]>
<ng-container [ngTemplateOutlet]="filterSection" [ngTemplateOutletContext]="{isOpen: isOpen}"></ng-container>
</div>
} @else {
<div>
<app-drawer #commentDrawer="drawer" [isOpen]="isOpen" [options]="{topOffset: 75}" (drawerClosed)="toggleService.set(false)" [width]="600">
<h5 header>
@ -17,60 +17,62 @@
</div>
</app-drawer>
</div>
</ng-template>
</ng-container>
</ng-container>
}
}
}
<ng-template #filterSection let-isOpen="isOpen">
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded && filterV2">
<div class="row justify-content-center g-0">
<app-metadata-builder [filter]="filterV2"
[availableFilterFields]="allFilterFields"
[statementLimit]="filterSettings.statementLimit"
(update)="handleFilters($event)">
</app-metadata-builder>
</div>
<form [formGroup]="sortGroup" class="container-fluid">
<div class="row mb-3">
<div class="col-md-2 col-sm-3">
<div class="form-group pe-1">
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
@if (fullyLoaded && filterV2) {
<div class="filter-section mx-auto pb-3">
<div class="row justify-content-center g-0">
<app-metadata-builder [filter]="filterV2"
[availableFilterFields]="allFilterFields"
[statementLimit]="filterSettings.statementLimit"
(update)="handleFilters($event)">
</app-metadata-builder>
</div>
<form [formGroup]="sortGroup" class="container-fluid">
<div class="row mb-3">
<div class="col-md-2 col-sm-3">
<div class="form-group pe-1">
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
</div>
</div>
</div>
<div class="col-md-3 col-sm-9">
<div class="col-md-3 col-sm-9">
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled">
<i class="fa fa-arrow-up" [title]="t('ascending-alt')" *ngIf="isAscendingSort; else descSort"></i>
<ng-template #descSort>
@if (isAscendingSort) {
<i class="fa fa-arrow-up" [title]="t('ascending-alt')"></i>
} @else {
<i class="fa fa-arrow-down" [title]="t('descending-alt')"></i>
</ng-template>
}
</button>
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
@for(field of allSortFields; track field.value) {
<option [value]="field.value">{{field.title}}</option>
}
</select>
</div>
<div class="col-md-4 col-sm-12" [ngClass]="{'mt-3': utilityService.getActiveBreakpoint() <= Breakpoint.Mobile}">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
<!-- <select2 [data]="smartFilters"-->
<!-- id="filter-name"-->
<!-- formControlName="name"-->
<!-- (update)="loadSavedFilter($event)"-->
<!-- (autoCreateItem)="createFilterValue($event)"-->
<!-- [autoCreate]="true"-->
<!-- displaySearchStatus="always"-->
<!-- [resettable]="true">-->
<!-- </select2>-->
</div>
<div class="col-md-4 col-sm-12" [ngClass]="{'mt-3': utilityService.getActiveBreakpoint() <= Breakpoint.Mobile}">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
</div>
@if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) {
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
}
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
</div>
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
</div>
</form>
</div>
@if (utilityService.getActiveBreakpoint() <= Breakpoint.Tablet) {
<div class="row mb-3">
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
</div>
}
</form>
</div>
}
</ng-template>
<ng-template #buttons>

View file

@ -11,7 +11,7 @@ import {
Output
} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap';
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import {Library} from '../_models/library/library';
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
@ -19,28 +19,15 @@ import {ToggleService} from '../_services/toggle.service';
import {FilterSettings} from './filter-settings';
import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
import {DrawerComponent} from '../shared/drawer/drawer.component';
import {AsyncPipe, NgClass, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoModule} from "@jsverse/transloco";
import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {SortFieldPipe} from "../_pipes/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
import {MetadataService} from "../_services/metadata.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
import {
Select2AutoCreateEvent,
Select2Module,
Select2Option,
Select2UpdateEvent,
Select2UpdateValue
} from "ng-select2-component";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {animate, state, style, transition, trigger} from "@angular/animations";
const ANIMATION_SPEED = 750;
import {animate, style, transition, trigger} from "@angular/animations";
@Component({
selector: 'app-metadata-filter',
@ -71,65 +58,53 @@ const ANIMATION_SPEED = 750;
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe,
MetadataBuilderComponent, NgForOf, Select2Module, NgClass]
imports: [NgTemplateOutlet, DrawerComponent,
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
MetadataBuilderComponent, NgClass]
})
export class MetadataFilterComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
private readonly filterService = inject(FilterService);
protected readonly toggleService = inject(ToggleService);
protected readonly translocoService = inject(TranslocoService);
private readonly sortFieldPipe = new SortFieldPipe(this.translocoService);
/**
* This toggles the opening/collapsing of the metadata filter code
*/
@Input() filterOpen: EventEmitter<boolean> = new EventEmitter();
/**
* Should filtering be shown on the page
*/
@Input() filteringDisabled: boolean = false;
@Input({required: true}) filterSettings!: FilterSettings;
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
public readonly filterUtilitiesService = inject(FilterUtilitiesService);
/**
* Controls the visibility of extended controls that sit below the main header.
*/
filteringCollapsed: boolean = true;
libraries: Array<FilterItem<Library>> = [];
sortGroup!: FormGroup;
isAscendingSort: boolean = true;
updateApplied: number = 0;
fullyLoaded: boolean = false;
filterV2: SeriesFilterV2 | undefined;
allSortFields = allSortFields;
allFilterFields = allFields;
smartFilters!: Array<Select2Option>;
protected readonly allSortFields = allSortFields.map(f => {
return {title: this.sortFieldPipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
protected readonly allFilterFields = allFields;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
constructor(public toggleService: ToggleService, private filterService: FilterService) {
this.filterService.getAllFilters().subscribe(res => {
this.smartFilters = res.map(r => {
return {
value: r,
label: r.name,
}
});
});
}
ngOnInit(): void {
if (this.filterSettings === undefined) {

View file

@ -9,7 +9,7 @@
<app-cover-image [entity]="series" [coverImage]="imageService.getSeriesCoverImage(series.id)" [continueTitle]="ContinuePointTitle" (read)="read()"></app-cover-image>
</div>
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12">
<div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
<h4 class="title mb-2">
<span>{{series.name}}

View file

@ -15,7 +15,7 @@
</div>
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
@if (showEdit) {
<button type="button" class="btn btn-text btn-sm" (click)="toggleEditMode()" [disabled]="!canEdit">
<button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
</button>
}
@ -28,7 +28,7 @@
@if (isEditMode) {
<ng-container [ngTemplateOutlet]="valueEditRef"></ng-container>
} @else {
<span class="view-value" (click)="toggleEditMode()">
<span class="view-value" [ngClass]="{'non-selectable': (!canEdit || !showEdit)}" (click)="toggleEditMode()">
<ng-container [ngTemplateOutlet]="valueViewRef"></ng-container>
</span>
}

View file

@ -12,23 +12,11 @@
cursor: pointer;
}
//.setting-title.no-anim.edit:hover ~ .edit-btn {
// opacity: 1;
// transition: none;
//}
//
//.edit-btn {
// opacity: 0;
// transition: opacity 0.5s ease-out;
// transition-delay: 0.5s;
//
// &:hover {
// opacity: 1;
// transition: opacity 0.3s ease-out;
// }
//}
//
//.setting-title.no-anim + .edit-btn,
//.setting-title.no-anim.edit:hover ~ .edit-btn {
// transition: none !important;
//}
.non-selectable {
cursor: default;
}
.btn-alignment {
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}

View file

@ -8,7 +8,7 @@ import {
TemplateRef
} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {NgTemplateOutlet} from "@angular/common";
import {NgClass, NgTemplateOutlet} from "@angular/common";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {filter, fromEvent, tap} from "rxjs";
import {AbstractControl} from "@angular/forms";
@ -19,7 +19,8 @@ import {AbstractControl} from "@angular/forms";
imports: [
TranslocoDirective,
NgTemplateOutlet,
SafeHtmlPipe
SafeHtmlPipe,
NgClass
],
templateUrl: './setting-item.component.html',
styleUrl: './setting-item.component.scss',

View file

@ -7,7 +7,13 @@
}
</div>
<div class="col-auto">
<h6 class="section-title" [id]="id || title">{{title}}</h6>
<h6 class="section-title" [id]="id || title">
@if (labelId) {
<label class="reset-label" [for]="labelId">{{title}}</label>
} @else {
{{title}}
}
</h6>
</div>
</div>

View file

@ -1,7 +1,8 @@
import {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, ContentChild,
Component, ContentChild, ElementRef,
inject,
Input,
TemplateRef
@ -22,12 +23,39 @@ import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
styleUrl: './setting-switch.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SettingSwitchComponent {
export class SettingSwitchComponent implements AfterContentInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly elementRef = inject(ElementRef);
@Input({required:true}) title: string = '';
@Input() subtitle: string | undefined = undefined;
@Input() id: string | undefined = undefined;
@ContentChild('switch') switchRef!: TemplateRef<any>;
/**
* For wiring up with a real label
*/
labelId: string = '';
ngAfterContentInit(): void {
setTimeout(() => {
if (this.id) {
this.labelId = this.id;
this.cdRef.markForCheck();
return;
}
const element = this.elementRef.nativeElement;
const inputElement = element.querySelector('input');
if (inputElement && inputElement.id) {
this.labelId = inputElement.id;
this.cdRef.markForCheck();
} else {
console.warn('No input with ID found in app-setting-switch. For accessibility, please ensure the input has an ID.');
}
});
}
}

View file

@ -1,15 +1,21 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div class="settings-row g-0 row">
<div class="row g-0 mb-2">
<div class="col-10">
<h6 class="section-title" [id]="id || title">{{title}}
<div class="col-auto">
<h6 class="section-title" [id]="id || title">
@if (labelId) {
<label class="reset-label" [for]="labelId">{{title}}</label>
} @else {
{{title}}
}
@if (titleExtraRef) {
<ng-container [ngTemplateOutlet]="titleExtraRef"></ng-container>
}
</h6>
</div>
<div class="col-2 text-end align-self-end justify-content-end">
<button type="button" class="btn btn-text btn-sm" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
<button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
</div>
</div>
</div>

View file

@ -0,0 +1,4 @@
.btn-alignment {
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}

View file

@ -26,6 +26,10 @@ export class SettingTitleComponent {
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required:true}) title: string = '';
/**
* If passed, will generate a proper label element. Requires `id` to be passed as well
*/
@Input() labelId: string | undefined = undefined;
@Input() id: string | undefined = undefined;
@Input() canEdit: boolean = true;
@Input() isEditMode: boolean = false;

View file

@ -28,6 +28,10 @@ export class BadgeExpanderComponent implements OnInit, OnChanges {
@Input() itemsTillExpander: number = 4;
@Input() allowToggle: boolean = true;
@Input() includeComma: boolean = true;
/**
* If should be expanded by default. Defaults to false.
*/
@Input() defaultExpanded: boolean = false;
/**
* Invoked when the "and more" is clicked
*/
@ -39,10 +43,20 @@ export class BadgeExpanderComponent implements OnInit, OnChanges {
isCollapsed: boolean = false;
get itemsLeft() {
if (this.defaultExpanded) return 0;
return Math.max(this.items.length - this.itemsTillExpander, 0);
}
ngOnInit(): void {
if (this.defaultExpanded) {
this.isCollapsed = false;
this.visibleItems = this.items;
this.cdRef.markForCheck();
return;
}
this.visibleItems = this.items.slice(0, this.itemsTillExpander);
this.cdRef.markForCheck();
}

View file

@ -20,8 +20,8 @@
<div class="mb-3">
<label for="library-name" class="form-label">{{t('name-label')}}</label>
@if (libraryForm.get('name'); as formControl) {
<input id="library-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="formControl.invalid && formControl.touched">
@if (libraryForm.dirty || libraryForm.touched) {
<input id="library-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (libraryForm.dirty || !libraryForm.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('required-field')}}</div>
@ -52,8 +52,8 @@
<ng-container [ngTemplateOutlet]="typeTooltip"></ng-container>
</span>
<select class="form-select" id="library-type" formControlName="type" aria-describedby="library-type-help">
@for (opt of libraryTypes; track opt; let i = $index) {
<option [value]="i">{{opt}}</option>
@for (opt of libraryTypes; track opt.value) {
<option [value]="opt.value">{{opt.title}}</option>
}
</select>

View file

@ -28,7 +28,7 @@ import {
} from 'src/app/admin/_modals/directory-picker/directory-picker.component';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Library, LibraryType} from 'src/app/_models/library/library';
import {allLibraryTypes, Library, LibraryType} from 'src/app/_models/library/library';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
import {UploadService} from 'src/app/_services/upload.service';
@ -47,6 +47,7 @@ import {SettingSwitchComponent} from "../../../settings/_components/setting-swit
import {SettingButtonComponent} from "../../../settings/_components/setting-button/setting-button.component";
import {Action, ActionFactoryService, ActionItem} from "../../../_services/action-factory.service";
import {ActionService} from "../../../_services/action.service";
import {LibraryTypePipe} from "../../../_pipes/library-type.pipe";
enum TabID {
General = 'general-tab',
@ -68,7 +69,7 @@ enum StepID {
standalone: true,
imports: [CommonModule, NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip,
SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe,
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent],
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypePipe],
templateUrl: './library-settings-modal.component.html',
styleUrls: ['./library-settings-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -94,6 +95,7 @@ export class LibrarySettingsModalComponent implements OnInit {
protected readonly TabID = TabID;
protected readonly WikiLink = WikiLink;
protected readonly Action = Action;
protected readonly libraryTypePipe = new LibraryTypePipe();
@Input({required: true}) library!: Library | undefined;
@ -119,7 +121,9 @@ export class LibrarySettingsModalComponent implements OnInit {
selectedFolders: string[] = [];
madeChanges = false;
libraryTypes: string[] = []
libraryTypes = allLibraryTypes.map(f => {
return {title: this.libraryTypePipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
isAddLibrary = false;
setupStep = StepID.General;
@ -134,11 +138,6 @@ export class LibrarySettingsModalComponent implements OnInit {
}
ngOnInit(): void {
this.settingService.getLibraryTypes().subscribe((types) => {
this.libraryTypes = types;
this.cdRef.markForCheck();
});
if (this.library === undefined) {
this.isAddLibrary = true;
this.cdRef.markForCheck();

View file

@ -1,117 +1,113 @@
<ng-container *transloco="let t; read:'server-stats'">
<div class="container-fluid">
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-series-label')" [clickable]="false" fontClasses="fa-solid fa-book-open" [title]="t('total-series-tooltip', {count: stats.seriesCount | number})">
{{t('series-count', {num: stats.seriesCount | number})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-volumes-label')" [clickable]="false" fontClasses="fas fa-book" [title]="t('total-volumes-tooltip', {count: stats.volumeCount | number})">
{{t('volume-count', {num: stats.volumeCount | number})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-files-label')" [clickable]="false" fontClasses="fa-regular fa-file" [title]="t('total-files-tooltip', {count: stats.totalFiles | number})">
{{t('file-count', {num: stats.totalFiles | number})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-size-label')" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" [title]="t('total-size-label')">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-genres-label')" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-genres-tooltip', {count: stats.totalGenres | number})" (click)="openGenreList();$event.stopPropagation();">
{{t('genre-count', {num: stats.totalGenres | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-tags-label')" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-tags-tooltip', {count: stats.totalTags | number})" (click)="openTagList();$event.stopPropagation();">
{{t('tag-count', {num: stats.totalTags | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-people-label')" [clickable]="true" fontClasses="fa-solid fa-user-tag" [title]="t('total-people-tooltip', {count: stats.totalPeople | number})" (click)="openPeopleList();$event.stopPropagation();">
{{t('people-count', {num: stats.totalPeople | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-read-time-label')" [clickable]="false" fontClasses="fas fa-eye" [title]="t('total-read-time-tooltip', {count: stats.totalReadingTime | number})">
{{stats.totalReadingTime | timeDuration}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<div class="grid row g-0 pt-2 pb-2 d-flex justify-content-around">
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" [title]="t('release-years-title')" [label]="t('series')"></app-stat-list>
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-series-label')" [clickable]="false" fontClasses="fa-solid fa-book-open" [title]="t('total-series-tooltip', {count: stats.seriesCount | number})">
{{t('series-count', {num: stats.seriesCount | number})}}
</app-icon-and-title>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" [title]="t('most-active-users-title')" [label]="t('reads')"></app-stat-list>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-volumes-label')" [clickable]="false" fontClasses="fas fa-book" [title]="t('total-volumes-tooltip', {count: stats.volumeCount | number})">
{{t('volume-count', {num: stats.volumeCount | number})}}
</app-icon-and-title>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" [title]="t('popular-libraries-title')" [label]="t('reads')"></app-stat-list>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-files-label')" [clickable]="false" fontClasses="fa-regular fa-file" [title]="t('total-files-tooltip', {count: stats.totalFiles | number})">
{{t('file-count', {num: stats.totalFiles | number})}}
</app-icon-and-title>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" [title]="t('popular-series-title')" [image]="seriesImage" [handleClick]="openSeries">
</app-stat-list>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-size-label')" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" [title]="t('total-size-label')">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" [title]="t('recently-read-title')" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-genres-label')" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-genres-tooltip', {count: stats.totalGenres | number})" (click)="openGenreList();$event.stopPropagation();">
{{t('genre-count', {num: stats.totalGenres | compactNumber})}}
</app-icon-and-title>
</div>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers>
</div>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-tags-label')" [clickable]="true" fontClasses="fa-solid fa-tags" [title]="t('total-tags-tooltip', {count: stats.totalTags | number})" (click)="openTagList();$event.stopPropagation();">
{{t('tag-count', {num: stats.totalTags | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<div class="row g-0 pt-4 pb-2">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="row g-0 pt-4 pb-2">
<app-publication-status-stats></app-publication-status-stats>
</div>
<div class="row g-0 pt-4 pb-2">
<app-reading-activity [isAdmin]="true"></app-reading-activity>
</div>
<div class="row g-0 pt-4 pb-2">
<app-day-breakdown></app-day-breakdown>
</div>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-people-label')" [clickable]="true" fontClasses="fa-solid fa-user-tag" [title]="t('total-people-tooltip', {count: stats.totalPeople | number})" (click)="openPeopleList();$event.stopPropagation();">
{{t('people-count', {num: stats.totalPeople | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('total-read-time-label')" [clickable]="false" fontClasses="fas fa-eye" [title]="t('total-read-time-tooltip', {count: stats.totalReadingTime | number})">
{{stats.totalReadingTime | timeDuration}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<div class="grid row g-0 pt-2 pb-2 d-flex justify-content-around">
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" [title]="t('release-years-title')" [label]="t('series')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" [title]="t('most-active-users-title')" [label]="t('reads')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" [title]="t('popular-libraries-title')" [label]="t('reads')"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" [title]="t('popular-series-title')" [image]="seriesImage" [handleClick]="openSeries">
</app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" [title]="t('recently-read-title')" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
</div>
</div>
<div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-4 pb-2">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="row g-0 pt-4 pb-2">
<app-publication-status-stats></app-publication-status-stats>
</div>
<div class="row g-0 pt-4 pb-2">
<app-reading-activity [isAdmin]="true"></app-reading-activity>
</div>
<div class="row g-0 pt-4 pb-2">
<app-day-breakdown></app-day-breakdown>
</div>
</ng-container>

View file

@ -2,7 +2,8 @@
<app-setting-item [title]="title" [showEdit]="false" [canEdit]="false" [subtitle]="tooltipText" [toggleOnViewClick]="false">
<ng-template #view>
<input #apiKey [type]="InputType" readonly class="d-inline-flex form-control" style="width: 80%" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<input #apiKey [type]="InputType" readonly class="d-inline-flex form-control" style="width: 80%" id="api-key--{{title}}"
aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div class="d-inline-flex">
@if (hideData) {
@ -14,17 +15,10 @@
<button class="btn btn-primary-text" type="button" (click)="copy()" [title]="t('copy')">
{{t('copy')}}
</button>
@if (showRefresh) {
<button class="btn btn-danger-text" [ngbTooltip]="t('regen-warning')" (click)="refresh()">{{t('reset')}}</button>
}
</div>
</ng-template>
@if (showRefresh) {
<ng-template #titleActions>
<button class="btn btn-danger-outline" [ngbTooltip]="tipContent" (click)="refresh()">Reset</button>
</ng-template>
}
</app-setting-item>
<ng-template #tipContent>
{{t('regen-warning')}}
</ng-template>
</ng-container>

View file

@ -337,7 +337,8 @@
"regen-warning": "Regenerating your API key will invalidate any existing clients.",
"no-key": "ERROR - KEY NOT SET",
"confirm-reset": "This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?",
"key-reset": "API Key reset"
"key-reset": "API Key reset",
"reset": "Reset"
},
"scrobbling-providers": {
@ -571,9 +572,9 @@
"library-type-pipe": {
"book": "Book",
"comic": "Comic",
"comic": "Comic (Legacy)",
"manga": "Manga",
"comicVine": "Comic Vine",
"comicVine": "Comic",
"image": "Image",
"lightNovel": "Light Novel"
},
@ -1576,10 +1577,6 @@
"download-logs-task": "Download Logs",
"download-logs-task-desc": "Compiles all log files into a zip and downloads it.",
"analyze-files-task": "Analyze Files",
"analyze-files-task-desc": "Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.",
"analyze-files-task-success": "File analysis has been queued",
"sync-themes-task": "Sync Themes",
"sync-themes-task-desc": "Synchronize downloaded themes with upstream changes if version matches.",
"sync-themes-success": "Synchronization of themes has been queued",