A boatload of Bugs (#3704)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-04-05 15:52:01 -05:00 committed by GitHub
parent ea9b7ad0d1
commit 37734554ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 2051 additions and 1115 deletions

View file

@ -1,6 +1,5 @@
import { MangaFormat } from "../manga-format";
import { SeriesFilterV2 } from "./v2/series-filter-v2";
import {FilterField} from "./v2/filter-field";
import {MangaFormat} from "../manga-format";
import {SeriesFilterV2} from "./v2/series-filter-v2";
export interface FilterItem<T> {
title: string;
@ -34,22 +33,22 @@ export const allSortFields = Object.keys(SortField)
export const mangaFormatFilters = [
{
title: 'Images',
title: 'images',
value: MangaFormat.IMAGE,
selected: false
},
{
title: 'EPUB',
title: 'epub',
value: MangaFormat.EPUB,
selected: false
},
{
title: 'PDF',
title: 'pdf',
value: MangaFormat.PDF,
selected: false
},
{
title: 'ARCHIVE',
title: 'archive',
value: MangaFormat.ARCHIVE,
selected: false
}

View file

@ -6,7 +6,7 @@ export enum WikiLink {
SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships',
Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks',
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',
MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/',
KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id',
KavitaPlus = 'https://wiki.kavitareader.com/kavita+/features/',
KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq',

View file

@ -1,22 +1,22 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import { Observable, of } from 'rxjs';
import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import {AgeRating} from '../_models/metadata/age-rating';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
import {TranslocoService} from "@jsverse/transloco";
@Pipe({
name: 'ageRating',
standalone: true
standalone: true,
pure: true
})
export class AgeRatingPipe implements PipeTransform {
translocoService = inject(TranslocoService);
private readonly translocoService = inject(TranslocoService);
transform(value: AgeRating | AgeRatingDto | undefined): Observable<string> {
if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string);
transform(value: AgeRating | AgeRatingDto | undefined): string {
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
if (value.hasOwnProperty('title')) {
return of((value as AgeRatingDto).title);
return (value as AgeRatingDto).title;
}
switch (value) {
@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform {
return this.translocoService.translate('age-rating-pipe.r18-plus');
}
return of(this.translocoService.translate('age-rating-pipe.unknown') as string);
return this.translocoService.translate('age-rating-pipe.unknown');
}
}

View file

@ -0,0 +1,17 @@
import {Pipe, PipeTransform} from '@angular/core';
import {translate} from "@jsverse/transloco";
/**
* Transforms the log level string into a localized string
*/
@Pipe({
name: 'logLevel',
standalone: true,
pure: true
})
export class LogLevelPipe implements PipeTransform {
transform(value: string): string {
return translate('log-level-pipe.' + value.toLowerCase());
}
}

View file

@ -0,0 +1,15 @@
import {Pipe, PipeTransform} from '@angular/core';
import {Role} from "../_services/account.service";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'roleLocalized'
})
export class RoleLocalizedPipe implements PipeTransform {
transform(value: Role | string): string {
const key = (value + '').toLowerCase().replace(' ', '-');
return translate(`role-localized-pipe.${key}`);
}
}

View file

@ -1,14 +1,14 @@
import { Injectable } from '@angular/core';
import { map, Observable, shareReplay } from 'rxjs';
import { Chapter } from '../_models/chapter';
import {Injectable} from '@angular/core';
import {map, Observable, shareReplay} from 'rxjs';
import {Chapter} from '../_models/chapter';
import {UserCollection} from '../_models/collection-tag';
import { Device } from '../_models/device/device';
import { Library } from '../_models/library/library';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
import { DeviceService } from './device.service';
import {Device} from '../_models/device/device';
import {Library} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list';
import {Series} from '../_models/series';
import {Volume} from '../_models/volume';
import {AccountService} from './account.service';
import {DeviceService} from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco";
@ -170,6 +170,8 @@ export class ActionFactoryService {
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
sideNavHomeActions: Array<ActionItem<void>> = [];
isAdmin = false;
@ -226,6 +228,10 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.personActions, callback);
}
getSideNavHomeActions(callback: ActionCallback<void>) {
return this.applyCallbackToList(this.sideNavHomeActions, callback);
}
dummyCallback(action: ActionItem<any>, data: any) {}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
@ -873,6 +879,19 @@ export class ActionFactoryService {
children: [],
},
];
this.sideNavHomeActions = [
{
action: Action.Edit,
title: 'reorder',
description: '',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
}
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {

View file

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import {Injectable} from '@angular/core';
import {TextResonse} from "../_types/text-response";
import { HttpClient } from "@angular/common/http";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
@ -26,4 +26,8 @@ export class DashboardService {
createDashboardStream(smartFilterId: number) {
return this.httpClient.post<DashboardStream>(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
}
deleteSmartFilterStream(streamId: number) {
return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {});
}
}

View file

@ -23,4 +23,8 @@ export class FilterService {
return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId);
}
renameSmartFilter(filter: SmartFilter) {
return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {});
}
}

View file

@ -1,11 +1,9 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {catchError, map, of, ReplaySubject, tap, throwError} from "rxjs";
import {catchError, map, ReplaySubject, tap, throwError} from "rxjs";
import {environment} from "../../environments/environment";
import { TextResonse } from '../_types/text-response';
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'
@ -58,7 +56,6 @@ export class LicenseService {
}
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"),

View file

@ -1,7 +1,7 @@
import {DOCUMENT} from '@angular/common';
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs';
import { HttpClient } from "@angular/common/http";
import {filter, ReplaySubject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response";
@ -93,6 +93,10 @@ export class NavService {
return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility});
}
deleteSideNavSmartFilter(streamId: number) {
return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {});
}
/**
* Shows the top nav bar. This should be visible on all pages except the reader.
*/

View file

@ -7,7 +7,9 @@
<div class="modal-body scrollable-modal" [ngClass]="{'d-flex': utilityService.getActiveBreakpoint() !== Breakpoint.Mobile}">
<form [formGroup]="editForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills"
[destroyOnHide]="false"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<!-- General Tab -->
@if (user && accountService.hasAdminRole(user))
@ -102,20 +104,22 @@
<div class="row">
<div class="col-lg-9 col-md-12">
<div class="mb-3">
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
(newItemAdded)="chapter.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (languageSettings) {
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
(newItemAdded)="chapter.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
@ -166,39 +170,43 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
(newItemAdded)="chapter.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (genreSettings) {
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
(newItemAdded)="chapter.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
(newItemAdded)="chapter.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (tagsSettings) {
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
(newItemAdded)="chapter.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -207,39 +215,44 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
(newItemAdded)="chapter.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Imprint); as settings) {
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true"
[settings]="settings"
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
(newItemAdded)="chapter.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
(newItemAdded)="chapter.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Publisher); as settings) {
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="settings"
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
(newItemAdded)="chapter.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -248,39 +261,43 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
(newItemAdded)="chapter.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Team); as settings) {
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="settings"
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
(newItemAdded)="chapter.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
(newItemAdded)="chapter.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Location); as settings) {
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="settings"
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
(newItemAdded)="chapter.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -289,20 +306,22 @@
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
(newItemAdded)="chapter.characterLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Character); as settings) {
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="settings"
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
(newItemAdded)="chapter.characterLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -322,39 +341,43 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
(newItemAdded)="chapter.writerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Writer); as settings) {
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="settings"
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
(newItemAdded)="chapter.writerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
(newItemAdded)="chapter.coverArtistLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.CoverArtist); as settings) {
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="settings"
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
(newItemAdded)="chapter.coverArtistLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -363,39 +386,43 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
(newItemAdded)="chapter.pencillerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if(getPersonsSettings(PersonRole.Penciller); as settings) {
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="settings"
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
(newItemAdded)="chapter.pencillerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
(newItemAdded)="chapter.coloristLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if(getPersonsSettings(PersonRole.Colorist); as settings) {
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="settings"
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
(newItemAdded)="chapter.coloristLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -404,39 +431,43 @@
<div class="row">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
(newItemAdded)="chapter.inkerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if(getPersonsSettings(PersonRole.Inker); as settings) {
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="settings"
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
(newItemAdded)="chapter.inkerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
(newItemAdded)="chapter.lettererLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if(getPersonsSettings(PersonRole.Letterer); as settings) {
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="settings"
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
(newItemAdded)="chapter.lettererLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -445,20 +476,22 @@
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
(newItemAdded)="chapter.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if(getPersonsSettings(PersonRole.Translator); as settings) {
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="settings"
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
(newItemAdded)="chapter.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>

View file

@ -1,20 +1,8 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {
AsyncPipe,
NgClass,
NgTemplateOutlet,
TitleCasePipe
} from "@angular/common";
import {
NgbActiveModal,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from "@ng-bootstrap/ng-bootstrap";
import {AsyncPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Chapter} from "../../_models/chapter";
@ -41,10 +29,8 @@ import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {MangaFormat} from "../../_models/manga-format";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {TranslocoDatePipe} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {ImageComponent} from "../../shared/image/image.component";
@ -53,7 +39,6 @@ import {ReadTimePipe} from "../../_pipes/read-time.pipe";
import {ChapterService} from "../../_services/chapter.service";
import {AgeRating} from "../../_models/metadata/age-rating";
import {User} from "../../_models/user";
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
enum TabID {
General = 'general-tab',
@ -258,16 +243,15 @@ export class EditChapterModalComponent implements OnInit {
const model = this.editForm.value;
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
// Patch in data from the model that is not typeahead (as those are updated during setting)
if (model.releaseDate === '') {
this.chapter.releaseDate = '0001-01-01T00:00:00';
} else {
this.chapter.releaseDate = model.releaseDate + 'T00:00:00';
}
this.chapter.ageRating = parseInt(model.ageRating + '', 10) as AgeRating;
this.chapter.genres = model.genres;
this.chapter.tags = model.tags;
this.chapter.sortOrder = model.sortOrder;
this.chapter.language = model.language;
this.chapter.titleName = model.titleName;
this.chapter.summary = model.summary;
this.chapter.isbn = model.isbn;
@ -359,6 +343,7 @@ export class EditChapterModalComponent implements OnInit {
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.tagsSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
if (this.chapter.tags) {
this.tagsSettings.savedData = this.chapter.tags;
@ -390,6 +375,7 @@ export class EditChapterModalComponent implements OnInit {
this.genreSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
this.genreSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
if (this.chapter.genres) {
this.genreSettings.savedData = this.chapter.genres;
@ -416,6 +402,7 @@ export class EditChapterModalComponent implements OnInit {
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
if (l !== undefined) {
@ -427,6 +414,7 @@ export class EditChapterModalComponent implements OnInit {
updateFromPreset(id: string, presetField: Array<Person> | undefined, role: PersonRole) {
const personSettings = this.createBlankPersonSettings(id, role)
if (presetField && presetField.length > 0) {
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
return fetch('').pipe(map(people => {
@ -437,10 +425,11 @@ export class EditChapterModalComponent implements OnInit {
this.cdRef.markForCheck();
return true;
}));
} else {
this.peopleSettings[role] = personSettings;
return of(true);
}
this.peopleSettings[role] = personSettings;
return of(true);
}
setupPersonTypeahead() {
@ -460,7 +449,7 @@ export class EditChapterModalComponent implements OnInit {
this.updateFromPreset('translator', this.chapter.translators, PersonRole.Translator),
this.updateFromPreset('teams', this.chapter.teams, PersonRole.Team),
this.updateFromPreset('locations', this.chapter.locations, PersonRole.Location),
]).pipe(map(results => {
]).pipe(map(_ => {
return of(true);
}));
}
@ -497,6 +486,8 @@ export class EditChapterModalComponent implements OnInit {
return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
});
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');
return personSettings;
}

View file

@ -35,12 +35,12 @@
[count]="pageInfo.totalElements"
[offset]="pageInfo.pageNumber"
[limit]="pageInfo.size"
[sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]"
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
>
<ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-modified-header')}}
{{t('created-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | utcToLocalTime | defaultValue }}

View file

@ -14,16 +14,19 @@
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
{{t('no-data')}}
</li>
@for (library of allLibraries; track library.name; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</div>
</div>

View file

@ -3,7 +3,6 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {NgFor, NgIf} from '@angular/common';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TranslocoDirective} from "@jsverse/transloco";
import {SelectionModel} from "../../../typeahead/_models/selection-model";
@ -13,7 +12,7 @@ import {SelectionModel} from "../../../typeahead/_models/selection-model";
templateUrl: './library-access-modal.component.html',
styleUrls: ['./library-access-modal.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoDirective],
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibraryAccessModalComponent implements OnInit {
@ -23,6 +22,7 @@ export class LibraryAccessModalComponent implements OnInit {
private readonly libraryService = inject(LibraryService);
@Input() member: Member | undefined;
allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;

View file

@ -7,9 +7,12 @@
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>{{t('error-label')}}</strong> {{errorMessage}}
</div>
@if (errorMessage !== '') {
<div class="alert alert-info">
<strong>{{t('error-label')}}</strong> {{errorMessage}}
</div>
}
<div class="mb-3">
<label for="password" class="form-label">{{t('new-password-label')}}</label>
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">

View file

@ -1,24 +1,23 @@
import {Component, inject, Input} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/auth/member';
import { AccountService } from 'src/app/_services/account.service';
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
import { NgIf } from '@angular/common';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {Member} from 'src/app/_models/auth/member';
import {AccountService} from 'src/app/_services/account.service';
import {SentenceCasePipe} from '../../../_pipes/sentence-case.pipe';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-reset-password-modal',
templateUrl: './reset-password-modal.component.html',
styleUrls: ['./reset-password-modal.component.scss'],
imports: [ReactiveFormsModule, NgIf, SentenceCasePipe, TranslocoDirective]
selector: 'app-reset-password-modal',
templateUrl: './reset-password-modal.component.html',
styleUrls: ['./reset-password-modal.component.scss'],
imports: [ReactiveFormsModule, SentenceCasePipe, TranslocoDirective]
})
export class ResetPasswordModalComponent {
private readonly toastr = inject(ToastrService);
private readonly accountService = inject(AccountService);
public readonly modal = inject(NgbActiveModal);
protected readonly modal = inject(NgbActiveModal);
@Input({required: true}) member!: Member;

View file

@ -14,12 +14,17 @@
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
{{t('required-field')}}
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required
[class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
@if (inviteForm.dirty || !inviteForm.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (email?.errors?.required) {
<div>
{{t('required-field')}}
</div>
}
</div>
</div>
}
</div>
</div>
@ -41,8 +46,7 @@
</form>
}
<ng-container *ngIf="emailLink !== ''">
@if (emailLink !== '') {
<h4>{{t('setup-user-title')}}</h4>
<p>{{t('setup-user-description')}}</p>
@if (inviteError) {
@ -52,17 +56,23 @@
}
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('setup-user-account-tooltip')" [hideData]="false" [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-{{invited ? 'primary' : 'secondary'}}" (click)="close()">
{{invited ? t('cancel') : t('close')}}
</button>
<button *ngIf="!invited" type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? t('inviting') : t('invite')}}</span>
</button>
@if (!invited) {
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
@if (isSending) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
<span>{{isSending ? t('inviting') : t('invite')}}</span>
</button>
}
</div>
</div>
</ng-container>

View file

@ -1,17 +1,16 @@
import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
import { InviteUserResponse } from 'src/app/_models/auth/invite-user-response';
import { Library } from 'src/app/_models/library/library';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
import { ApiKeyComponent } from '../../user-settings/api-key/api-key.component';
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
import {InviteUserResponse} from 'src/app/_models/auth/invite-user-response';
import {Library} from 'src/app/_models/library/library';
import {AgeRating} from 'src/app/_models/metadata/age-rating';
import {AccountService} from 'src/app/_services/account.service';
import {ApiKeyComponent} from '../../user-settings/api-key/api-key.component';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
import {RoleSelectorComponent} from '../role-selector/role-selector.component';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
@ -19,10 +18,16 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
selector: 'app-invite-user',
templateUrl: './invite-user.component.html',
styleUrls: ['./invite-user.component.scss'],
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent, TranslocoDirective, SafeHtmlPipe]
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent,
ApiKeyComponent, TranslocoDirective, SafeHtmlPipe]
})
export class InviteUserComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
protected readonly modal = inject(NgbActiveModal);
/**
* Maintains if the backend is sending an email
*/
@ -35,15 +40,13 @@ export class InviteUserComponent implements OnInit {
invited: boolean = false;
inviteError: boolean = false;
private readonly cdRef = inject(ChangeDetectorRef);
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
makeLink: (val: string) => string = (_: string) => {return this.emailLink};
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
public get email() { return this.inviteForm.get('email'); }
get email() { return this.inviteForm.get('email'); }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private toastr: ToastrService) { }
ngOnInit(): void {
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
@ -88,14 +91,17 @@ export class InviteUserComponent implements OnInit {
updateRoleSelection(roles: Array<string>) {
this.selectedRoles = roles;
this.cdRef.markForCheck();
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
this.cdRef.markForCheck();
}
updateRestrictionSelection(restriction: AgeRestriction) {
this.selectedRestriction = restriction;
this.cdRef.markForCheck();
}
}

View file

@ -2,22 +2,22 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit,
Output,
QueryList,
ViewChildren,
inject,
DestroyRef
ViewChildren
} from '@angular/core';
import { BehaviorSubject, Observable, filter, shareReplay } from 'rxjs';
import { SortEvent, SortableHeader, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { KavitaMediaError } from '../_models/media-error';
import { ServerService } from 'src/app/_services/server.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {BehaviorSubject, filter, Observable, shareReplay} from 'rxjs';
import {compare, SortableHeader, SortEvent} from 'src/app/_single-module/table/_directives/sortable-header.directive';
import {KavitaMediaError} from '../_models/media-error';
import {ServerService} from 'src/app/_services/server.service';
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { FilterPipe } from '../../_pipes/filter.pipe';
import {FilterPipe} from '../../_pipes/filter.pipe';
import {TranslocoDirective} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -28,8 +28,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
selector: 'app-manage-media-issues',
templateUrl: './manage-media-issues.component.html',
styleUrls: ['./manage-media-issues.component.scss'],
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule]
})
export class ManageMediaIssuesComponent implements OnInit {

View file

@ -170,14 +170,14 @@
@if (settingsForm.get('loggingLevel'); as formControl) {
<app-setting-item [title]="t('logging-level-label')" [subtitle]="t('logging-level-tooltip')">
<ng-template #view>
{{formControl.value | titlecase}}
{{formControl.value | logLevel}}
</ng-template>
<ng-template #edit>
<select id="logging-level" aria-describedby="logging-level-help" class="form-select" formControlName="loggingLevel"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@for(level of logLevels; track level) {
<option [value]="level">{{level | titlecase}}</option>
<option [value]="level">{{level | logLevel}}</option>
}
</select>

View file

@ -5,7 +5,6 @@ import {take} from 'rxjs/operators';
import {ServerService} from 'src/app/_services/server.service';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
@ -15,6 +14,7 @@ import {debounceTime, distinctUntilChanged, filter, switchMap, tap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {EnterBlurDirective} from "../../_directives/enter-blur.directive";
import {LogLevelPipe} from "../../_pipes/log-level.pipe";
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
@ -23,7 +23,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
templateUrl: './manage-settings.component.html',
styleUrls: ['./manage-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, EnterBlurDirective]
imports: [ReactiveFormsModule, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, EnterBlurDirective, LogLevelPipe]
})
export class ManageSettingsComponent implements OnInit {

View file

@ -36,13 +36,17 @@
</span>
</td>
<td>
@if (!hasAdminRole(member) && member.libraries.length > 0) {
@if (member.libraries.length > 5) {
{{t('too-many-libraries')}}
}
@else {
@for(lib of member.libraries; track lib.name) {
<app-tag-badge class="col-auto">{{lib.name}}</app-tag-badge>
@if (member.libraries.length > 0) {
@if (hasAdminRole(member)) {
{{t('all-libraries')}}
} @else {
@if (member.libraries.length > 5) {
{{t('too-many-libraries')}}
}
@else {
@for(lib of member.libraries; track lib.name) {
<app-tag-badge class="col-auto">{{lib.name}}</app-tag-badge>
}
}
}
} @else {
@ -56,10 +60,10 @@
<span>{{null | defaultValue}}</span>
} @else {
@if (hasAdminRole(member)) {
<app-tag-badge class="col-auto">{{t('admin')}}</app-tag-badge>
<app-tag-badge class="col-auto">{{Role.Admin | roleLocalized}}</app-tag-badge>
} @else {
@for (role of roles; track role) {
<app-tag-badge class="col-auto">{{role}}</app-tag-badge>
<app-tag-badge class="col-auto">{{role | roleLocalized}}</app-tag-badge>
}
}
}

View file

@ -3,7 +3,7 @@ import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {take} from 'rxjs/operators';
import {MemberService} from 'src/app/_services/member.service';
import {Member} from 'src/app/_models/auth/member';
import {AccountService} from 'src/app/_services/account.service';
import {AccountService, Role} from 'src/app/_services/account.service';
import {ToastrService} from 'ngx-toastr';
import {ResetPasswordModalComponent} from '../_modals/reset-password-modal/reset-password-modal.component';
import {ConfirmService} from 'src/app/shared/confirm.service';
@ -17,22 +17,26 @@ import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {makeBindingParser} from "@angular/compiler";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
@Component({
selector: 'app-manage-users',
templateUrl: './manage-users.component.html',
styleUrls: ['./manage-users.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe]
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
RoleLocalizedPipe]
})
export class ManageUsersComponent implements OnInit {
protected readonly Role = Role;
private readonly translocoService = inject(TranslocoService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly memberService = inject(MemberService);
@ -153,6 +157,4 @@ export class ManageUsersComponent implements OnInit {
getRoles(member: Member) {
return member.roles.filter(item => item != 'Pleb');
}
protected readonly makeBindingParser = makeBindingParser;
}

View file

@ -20,7 +20,7 @@
<div class="form-check">
<input id="role-{{i}}" type="checkbox" class="form-check-input"
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
<label for="role-{{i}}" class="form-check-label">{{role.data}}</label>
<label for="role-{{i}}" class="form-check-label">{{role.data | roleLocalized}}</label>
</div>
</li>
}

View file

@ -14,13 +14,14 @@ import {AccountService} from 'src/app/_services/account.service';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TranslocoDirective,} from "@jsverse/transloco";
import {SelectionModel} from "../../typeahead/_models/selection-model";
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
@Component({
selector: 'app-role-selector',
templateUrl: './role-selector.component.html',
styleUrls: ['./role-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective]
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, RoleLocalizedPipe]
})
export class RoleSelectorComponent implements OnInit {

View file

@ -4,24 +4,29 @@
<h4 title>
{{title}}
</h4>
<h5 subtitle *ngIf="pagination">{{t('series-count', {num: pagination.totalItems | number})}}</h5>
@if (pagination) {
<h5 subtitle>{{t('series-count', {num: pagination.totalItems | number})}}</h5>
}
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter"
[isLoading]="loadingSeries"
[items]="series"
[trackByIdentity]="trackByIdentity"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
@if (filter) {
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[trackByIdentity]="trackByIdentity"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
}
</ng-container>
</div>

View file

@ -1,48 +1,63 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
HostListener,
inject,
OnInit
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take, debounceTime } from 'rxjs/operators';
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { FilterEvent } from 'src/app/_models/metadata/series-filter';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { MessageHubService, Message, EVENTS } from 'src/app/_services/message-hub.service';
import { SeriesService } from 'src/app/_services/series.service';
import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {Action, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service';
import {EVENTS, Message, MessageHubService} from 'src/app/_services/message-hub.service';
import {SeriesService} from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SeriesCardComponent } from '../../../cards/series-card/series-card.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { NgIf, DecimalPipe } from '@angular/common';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {SeriesCardComponent} from '../../../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
import {DecimalPipe} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
@Component({
selector: 'app-all-series',
templateUrl: './all-series.component.html',
styleUrls: ['./all-series.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe, TranslocoDirective]
selector: 'app-all-series',
templateUrl: './all-series.component.html',
styleUrls: ['./all-series.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent,
DecimalPipe, TranslocoDirective],
})
export class AllSeriesComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly seriesService = inject(SeriesService);
private readonly titleService = inject(Title);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly route = inject(ActivatedRoute);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly bulkSelectionService = inject(BulkSelectionService);
title: string = translate('side-nav.all-series');
series: Series[] = [];
loadingSeries = false;
@ -53,7 +68,7 @@ export class AllSeriesComponent implements OnInit {
filterActiveCheck!: SeriesFilterV2;
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
private readonly destroyRef = inject(DestroyRef);
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -103,13 +118,10 @@ export class AllSeriesComponent implements OnInit {
}
}
constructor(private router: Router, private seriesService: SeriesService,
private titleService: Title, private actionService: ActionService,
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, private route: ActivatedRoute,
private filterUtilityService: FilterUtilitiesService, private jumpbarService: JumpbarService,
private readonly cdRef: ChangeDetectorRef) {
constructor() {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
@ -140,7 +152,7 @@ export class AllSeriesComponent implements OnInit {
return;
}
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => {
this.loadPage();
});
}
@ -163,5 +175,5 @@ export class AllSeriesComponent implements OnInit {
});
}
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
trackByIdentity = (_: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
}

View file

@ -18,7 +18,7 @@ import {
import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common';
import {ActivatedRoute, Router} from '@angular/router';
import {ToastrService} from 'ngx-toastr';
import {forkJoin, fromEvent, of} from 'rxjs';
import {forkJoin, fromEvent, merge, of} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators';
import {Chapter} from 'src/app/_models/chapter';
import {AccountService} from 'src/app/_services/account.service';
@ -515,7 +515,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.handleScrollEvent();
});
fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mousemove')
const mouseMove$ = fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mousemove');
const touchMove$ = fromEvent<TouchEvent>(this.bookContainerElemRef.nativeElement, 'touchmove');
merge(mouseMove$, touchMove$)
.pipe(
takeUntilDestroyed(this.destroyRef),
distinctUntilChanged(),
@ -527,7 +530,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
)
.subscribe();
fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mouseup')
const mouseUp$ = fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mouseup');
const touchEnd$ = fromEvent<TouchEvent>(this.bookContainerElemRef.nativeElement, 'touchend');
merge(mouseUp$, touchEnd$)
.pipe(
takeUntilDestroyed(this.destroyRef),
distinctUntilChanged(),

View file

@ -8,7 +8,8 @@
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" [destroyOnHide]="false"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>{{t(tabs[TabID.General])}}</a>
@ -94,20 +95,22 @@
<div class="row">
<div class="col-lg-8 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (languageSettings) {
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="col-lg-4 col-md-12">
@ -134,20 +137,22 @@
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateGenres($event);metadata.genresLocked = true" [settings]="genreSettings"
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
(newItemAdded)="metadata.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (genreSettings) {
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateGenres($event);metadata.genresLocked = true" [settings]="genreSettings"
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
(newItemAdded)="metadata.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -155,20 +160,22 @@
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateTags($event);metadata.tagsLocked = true" [settings]="tagsSettings"
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
(newItemAdded)="metadata.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (tagsSettings) {
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateTags($event);metadata.tagsLocked = true" [settings]="tagsSettings"
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
(newItemAdded)="metadata.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
</div>
@ -217,134 +224,148 @@
<ng-template ngbNavContent>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);metadata.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="metadata.writerLocked" (onUnlock)="metadata.writerLocked = false"
(newItemAdded)="metadata.writerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Writer); as settings) {
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);metadata.writerLocked = true" [settings]="settings"
[(locked)]="metadata.writerLocked" (onUnlock)="metadata.writerLocked = false"
(newItemAdded)="metadata.writerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);metadata.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="metadata.coverArtistLocked" (onUnlock)="metadata.coverArtistLocked = false"
(newItemAdded)="metadata.coverArtistLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.CoverArtist); as settings) {
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);metadata.coverArtistLocked = true" [settings]="settings"
[(locked)]="metadata.coverArtistLocked" (onUnlock)="metadata.coverArtistLocked = false"
(newItemAdded)="metadata.coverArtistLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
(newItemAdded)="metadata.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Publisher); as settings) {
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);metadata.publisherLocked = true" [settings]="settings"
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
(newItemAdded)="metadata.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
[(locked)]="metadata.imprintLocked" (onUnlock)="metadata.imprintLocked = false"
(newItemAdded)="metadata.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Imprint); as settings) {
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);metadata.publisherLocked = true" [settings]="settings"
[(locked)]="metadata.imprintLocked" (onUnlock)="metadata.imprintLocked = false"
(newItemAdded)="metadata.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);metadata.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="metadata.pencillerLocked" (onUnlock)="metadata.pencillerLocked = false"
(newItemAdded)="metadata.pencillerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Penciller); as settings) {
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);metadata.pencillerLocked = true" [settings]="settings"
[(locked)]="metadata.pencillerLocked" (onUnlock)="metadata.pencillerLocked = false"
(newItemAdded)="metadata.pencillerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);metadata.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="metadata.lettererLocked" (onUnlock)="metadata.lettererLocked = false"
(newItemAdded)="metadata.lettererLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Letterer); as settings) {
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);metadata.lettererLocked = true" [settings]="settings"
[(locked)]="metadata.lettererLocked" (onUnlock)="metadata.lettererLocked = false"
(newItemAdded)="metadata.lettererLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);metadata.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="metadata.inkerLocked" (onUnlock)="metadata.inkerLocked = false"
(newItemAdded)="metadata.inkerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Inker); as settings) {
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);metadata.inkerLocked = true" [settings]="settings"
[(locked)]="metadata.inkerLocked" (onUnlock)="metadata.inkerLocked = false"
(newItemAdded)="metadata.inkerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
@ -352,114 +373,126 @@
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('editor-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor);metadata.editorLocked = true" [settings]="getPersonsSettings(PersonRole.Editor)"
[(locked)]="metadata.editorLocked" (onUnlock)="metadata.editorLocked = false"
(newItemAdded)="metadata.editorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Editor); as settings) {
<app-setting-item [title]="t('editor-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor);metadata.editorLocked = true" [settings]="settings"
[(locked)]="metadata.editorLocked" (onUnlock)="metadata.editorLocked = false"
(newItemAdded)="metadata.editorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);metadata.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="metadata.coloristLocked" (onUnlock)="metadata.coloristLocked = false"
(newItemAdded)="metadata.coloristLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Colorist); as settings) {
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);metadata.coloristLocked = true" [settings]="settings"
[(locked)]="metadata.coloristLocked" (onUnlock)="metadata.coloristLocked = false"
(newItemAdded)="metadata.coloristLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
(newItemAdded)="metadata.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Translator); as settings) {
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="settings"
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
(newItemAdded)="metadata.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="metadata.characterLocked" (onUnlock)="metadata.characterLocked = false"
(newItemAdded)="metadata.characterLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Character); as settings) {
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.characterLocked = true" [settings]="settings"
[(locked)]="metadata.characterLocked" (onUnlock)="metadata.characterLocked = false"
(newItemAdded)="metadata.characterLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);metadata.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
[(locked)]="metadata.teamLocked" (onUnlock)="metadata.teamLocked = false"
(newItemAdded)="metadata.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Team); as settings) {
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);metadata.teamLocked = true" [settings]="settings"
[(locked)]="metadata.teamLocked" (onUnlock)="metadata.teamLocked = false"
(newItemAdded)="metadata.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row">
<div class="mb-3">
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location)" [settings]="getPersonsSettings(PersonRole.Location)"
[(locked)]="metadata.locationLocked" (onUnlock)="metadata.locationLocked = false"
(newItemAdded)="metadata.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
@if (getPersonsSettings(PersonRole.Location); as settings) {
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location)" [settings]="settings"
[(locked)]="metadata.locationLocked" (onUnlock)="metadata.locationLocked = false"
(newItemAdded)="metadata.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
}
</div>
</div>

View file

@ -373,6 +373,7 @@ export class EditSeriesModalComponent implements OnInit {
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.tagsSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
if (this.metadata.tags) {
this.tagsSettings.savedData = this.metadata.tags;
@ -404,6 +405,7 @@ export class EditSeriesModalComponent implements OnInit {
this.genreSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
this.genreSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
if (this.metadata.genres) {
this.genreSettings.savedData = this.metadata.genres;
@ -460,6 +462,7 @@ export class EditSeriesModalComponent implements OnInit {
if (l !== undefined) {
this.languageSettings.savedData = l;
}
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
this.cdRef.markForCheck();
}),
@ -520,6 +523,7 @@ export class EditSeriesModalComponent implements OnInit {
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
});
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');
return personSettings;
}

View file

@ -1,4 +1,5 @@
<ng-container *transloco="let t; read: 'card-detail-layout'">
<app-loading [loading]="isLoading"></app-loading>
@if (header.length > 0) {
<div class="row mt-2 g-0 pb-2">
@ -12,7 +13,7 @@
<span>
{{header}}&nbsp;
@if (pagination !== undefined) {
@if (pagination) {
<span class="badge bg-primary rounded-pill"
[attr.aria-label]="t('total-items', {count: pagination.totalItems})">{{pagination.totalItems}}</span>
}
@ -26,7 +27,7 @@
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<div class="viewport-container ms-1" [ngClass]="{'empty': items.length === 0 && !isLoading}">
<div class="content-container">
<div class="card-container mt-">
<div class="card-container">
@if (items.length === 0 && !isLoading) {
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
}
@ -62,15 +63,17 @@
@if (items.length === 0 && !isLoading) {
<div class="mx-auto" style="width: 200px;">
<p>
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
@if (noDataTemplate) {
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
} @else {
{{t('no-data')}}
}
</p>
</div>
}
</ng-template>
<app-loading [loading]="isLoading"></app-loading>
<ng-template #jumpBar>
<div class="jump-bar">
@for(jumpKey of jumpBarKeysToRender; track jumpKey.key; let i = $index) {

View file

@ -96,7 +96,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
@ContentChild('noData') noDataTemplate: TemplateRef<any> | null = null;
@ViewChild('.jump-bar') jumpBar!: ElementRef<HTMLDivElement>;
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;

View file

@ -1,8 +1,10 @@
<ng-container *transloco="let t; read: 'download-indicator'">
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="visually-hidden" role="status">
{{t('progress',{percentage: download.progress})}}
@if (download$ | async; as download) {
<span class="download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="visually-hidden" role="status">
{{t('progress',{percentage: download.progress})}}
</span>
</span>
</span>
}
</ng-container>

View file

@ -1,17 +1,17 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Observable } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadEvent } from 'src/app/shared/_services/download.service';
import {CommonModule} from "@angular/common";
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {Observable} from 'rxjs';
import {Download} from 'src/app/shared/_models/download';
import {DownloadEvent} from 'src/app/shared/_services/download.service';
import {CircularLoaderComponent} from "../../shared/circular-loader/circular-loader.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {AsyncPipe} from "@angular/common";
@Component({
selector: 'app-download-indicator',
imports: [CommonModule, CircularLoaderComponent, TranslocoDirective],
templateUrl: './download-indicator.component.html',
styleUrls: ['./download-indicator.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-download-indicator',
imports: [CircularLoaderComponent, TranslocoDirective, AsyncPipe],
templateUrl: './download-indicator.component.html',
styleUrls: ['./download-indicator.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DownloadIndicatorComponent {

View file

@ -29,6 +29,11 @@
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
<ng-template #noData>
<!-- TODO: Come back and figure this out -->
{{t('common.no-data')}}
</ng-template>
</app-card-detail-layout>
}

View file

@ -1,69 +1,80 @@
<ng-container *transloco="let t; read: 'metadata-builder'">
<ng-container *ngIf="filter">
<form [formGroup]="formGroup">
<ng-container *ngIf="utilityService.getActiveBreakpoint() === Breakpoint.Desktop; else mobileView">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="comparison">
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
</select>
</div>
@if (filter) {
<form [formGroup]="formGroup">
<div class="col-md-2">
<button type="button" class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button>
</div>
</div>
<div class="row mb-2" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-10">
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2">
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
</button>
</div>
</app-metadata-row-filter>
</div>
@if (utilityService.getActiveBreakpoint() === Breakpoint.Desktop) {
<div class="container-fluid">
<div class="row mb-2">
<div class="col-auto">
<select class="form-select" formControlName="comparison">
@for (opt of groupOptions; track opt.value) {
<option [value]="opt.value">{{opt.title}}</option>
}
</select>
</div>
</div>
</div>
</ng-container>
<div class="col-md-2">
<button type="button" class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button>
</div>
</div>
<ng-template #mobileView>
<div class="container-fluid">
<div class="row mb-3">
<div class="col-md-4 col-10">
<select class="form-select" formControlName="comparison">
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
</select>
</div>
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
<div class="row mb-2">
<div class="col-md-10">
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2">
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
</button>
}
</div>
</app-metadata-row-filter>
</div>
</div>
}
</div>
} @else {
<div class="container-fluid">
<div class="row mb-3">
<div class="col-md-4 col-10">
<select class="form-select" formControlName="comparison">
@for (opt of groupOptions; track opt.value) {
<option [value]="opt.value">{{opt.title}}</option>
}
</select>
</div>
<div class="col-md-2 col-1">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button>
</div>
</div>
<div class="row mb-3" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-12">
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2 col-1">
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule')}}</span>
</button>
</div>
</app-metadata-row-filter>
</div>
</div>
</div>
</ng-template>
</form>
</ng-container>
<div class="col-md-2 col-1">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button>
</div>
</div>
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
<div class="row mb-3">
<div class="col-md-12">
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2 col-1">
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule')}}</span>
</button>
}
</div>
</app-metadata-row-filter>
</div>
</div>
}
</div>
}
</form>
}
</ng-container>

View file

@ -1,7 +1,8 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
@ -11,10 +12,8 @@ import {
import {MetadataService} from 'src/app/_services/metadata.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {SeriesFilterV2} from 'src/app/_models/metadata/v2/series-filter-v2';
import {NgForOf, NgIf, UpperCasePipe} from "@angular/common";
import {MetadataFilterRowComponent} from "../metadata-filter-row/metadata-filter-row.component";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {FilterCombination} from "../../../_models/metadata/v2/filter-combination";
@ -25,21 +24,17 @@ import {distinctUntilChanged, tap} from "rxjs/operators";
import {translate, TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-metadata-builder',
templateUrl: './metadata-builder.component.html',
styleUrls: ['./metadata-builder.component.scss'],
imports: [
NgIf,
MetadataFilterRowComponent,
NgForOf,
CardActionablesComponent,
FormsModule,
NgbTooltip,
UpperCasePipe,
ReactiveFormsModule,
TranslocoDirective
],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-metadata-builder',
templateUrl: './metadata-builder.component.html',
styleUrls: ['./metadata-builder.component.scss'],
imports: [
MetadataFilterRowComponent,
FormsModule,
NgbTooltip,
ReactiveFormsModule,
TranslocoDirective
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetadataBuilderComponent implements OnInit {

View file

@ -25,7 +25,9 @@ import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Select2, Select2Option} from "ng-select2-component";
import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {AgeRatingPipe} from "../../../_pipes/age-rating.pipe";
enum PredicateType {
Text = 1,
@ -135,6 +137,18 @@ const BooleanComparisons = [
})
export class MetadataFilterRowComponent implements OnInit {
protected readonly FilterComparison = FilterComparison;
protected readonly PredicateType = PredicateType;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
private readonly metadataService = inject(MetadataService);
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly translocoService = inject(TranslocoService);
@Input() index: number = 0; // This is only for debugging
/**
* Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter
@ -144,12 +158,6 @@ export class MetadataFilterRowComponent implements OnInit {
@Output() filterStatement = new EventEmitter<FilterStatement>();
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
protected readonly FilterComparison = FilterComparison;
formGroup: FormGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
'filterValue': new FormControl<string | number>('', []),
@ -159,7 +167,9 @@ export class MetadataFilterRowComponent implements OnInit {
dropdownOptions$ = of<Select2Option[]>([]);
loaded: boolean = false;
protected readonly PredicateType = PredicateType;
private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService);
private readonly ageRatingPipe = new AgeRatingPipe();
get UiLabel(): FilterRowUi | null {
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
@ -172,8 +182,6 @@ export class MetadataFilterRowComponent implements OnInit {
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
}
constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService,
private readonly collectionTagService: CollectionTagService) {}
ngOnInit() {
this.formGroup.addControl('input', new FormControl<FilterField>(FilterField.SeriesName, []));
@ -272,7 +280,7 @@ export class MetadataFilterRowComponent implements OnInit {
})));
case FilterField.AgeRating:
return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, label: rating.title}
return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)}
})));
case FilterField.Genres:
return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => {
@ -284,7 +292,7 @@ export class MetadataFilterRowComponent implements OnInit {
})));
case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, label: status.title}
return {value: status.value, label: this.mangaFormatPipe.transform(status.value)}
})));
case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {

View file

@ -45,13 +45,16 @@
(progress)="updateLoadProgress($event)"
(zoomChange)="calcScrollbarNeeded()"
(handToolChange)="updateHandTool($event)"
(findbarVisibleChange)="updateSearchOpen($event)"
>
</ngx-extended-pdf-viewer>
@if (scrollMode === ScrollModeType.page && !isLoading) {
<div class="left" (click)="prevPage()"></div>
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
@if (!isSearchOpen) {
<div class="left" (click)="prevPage()"></div>
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
}
}
<ng-template #multiToolbar>

View file

@ -115,6 +115,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
pageLayoutMode: PageViewModeType = 'multiple';
scrollMode: ScrollModeType = ScrollModeType.vertical;
spreadMode: SpreadType = 'off';
isSearchOpen: boolean = false;
constructor(@Inject(DOCUMENT) private document: Document) {
this.navService.hideNavBar();
@ -353,6 +354,11 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
console.log('event.tool', event);
}
updateSearchOpen(event: boolean) {
this.isSearchOpen = event;
this.cdRef.markForCheck();
}
prevPage() {
this.currentPage--;
if (this.currentPage < 0) this.currentPage = 0;

View file

@ -91,11 +91,13 @@
</div>
</div>
<div class="col-auto ms-2 d-none d-md-block btn-actions">
<button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')">
<i class="fa-solid fa-list-ol" aria-hidden="true"></i>
</button>
</div>
@if (isOwnedReadingList) {
<div class="col-auto ms-2 d-none d-md-block btn-actions">
<button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')">
<i class="fa-solid fa-list-ol" aria-hidden="true"></i>
</button>
</div>
}
</div>
</div>

View file

@ -13,9 +13,9 @@
}
</h6>
</div>
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
<div class="col-auto text-end align-self-end justify-content-end">
@if (showEdit) {
<button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
<button type="button" class="btn btn-icon edit-btn btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
</button>
}

View file

@ -20,3 +20,7 @@
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}
.edit-btn {
color: var(--primary-color);
}

View file

@ -2,7 +2,9 @@ import {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, ContentChild, ElementRef,
Component,
ContentChild,
ElementRef,
inject,
Input,
TemplateRef
@ -48,6 +50,9 @@ export class SettingSwitchComponent implements AfterContentInit {
const element = this.elementRef.nativeElement;
const inputElement = element.querySelector('input');
// If no id, generate a random id and assign it to the input
inputElement.id = crypto.randomUUID();
if (inputElement && inputElement.id) {
this.labelId = inputElement.id;
this.cdRef.markForCheck();

View file

@ -14,8 +14,8 @@
</h6>
</div>
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
<button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
<div class="col-auto text-end align-self-end justify-content-end">
<button type="button" class="btn btn-icon edit-btn btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
</div>
</div>
</div>

View file

@ -2,3 +2,6 @@
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}
.edit-btn {
color: var(--primary-color);
}

View file

@ -1,11 +1,16 @@
<div class="d-flex justify-content-center align-self-center align-items-center icon-and-title"
[ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''" (click)="handleClick($event)">
<div class="label" *ngIf="label && label.length > 0">
{{label}}
</div>
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
[ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''"
(click)="handleClick($event)">
<div class="text">
<ng-content></ng-content>
@if (label && label.length > 0) {
<div class="label">
{{label}}
</div>
}
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
<div class="text">
<ng-content></ng-content>
</div>
</div>

View file

@ -1,12 +1,14 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {CommonModule} from "@angular/common";
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
import {NgClass} from "@angular/common";
@Component({
selector: 'app-icon-and-title',
imports: [CommonModule],
templateUrl: './icon-and-title.component.html',
styleUrls: ['./icon-and-title.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-icon-and-title',
imports: [
NgClass
],
templateUrl: './icon-and-title.component.html',
styleUrls: ['./icon-and-title.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class IconAndTitleComponent {
/**

View file

@ -2,30 +2,38 @@
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-position="idx" let-item>
<app-dashboard-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-dashboard-stream-list-item>
<app-dashboard-stream-list-item [item]="item" [position]="position"
(hide)="updateVisibility($event, position)"
(delete)="delete($event)"
/>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</h5>
<h5>{{t('smart-filter-title')}}</h5>
<form [formGroup]="listForm">
<div class="mb-3" *ngIf="smartFilters.length >= 3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
@if (smartFilters.length >= 3) {
<div class="mb-3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
</div>
</div>
</div>
}
</form>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters | filter: filterList">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
{{t('no-data')}}
</li>
@for (filter of smartFilters | filter: filterList; track filter) {
<li class="filter list-group-item">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</ng-container>

View file

@ -1,14 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
DraggableOrderedListComponent, IndexUpdateEvent
DraggableOrderedListComponent,
IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {DashboardService} from "../../../_services/dashboard.service";
import {FilterService} from "../../../_services/filter.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {forkJoin} from "rxjs";
import {TranslocoDirective} from "@jsverse/transloco";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
@ -17,7 +16,8 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
@Component({
selector: 'app-customize-dashboard-streams',
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, ReactiveFormsModule, FilterPipe],
imports: [DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective,
ReactiveFormsModule, FilterPipe],
templateUrl: './customize-dashboard-streams.component.html',
styleUrls: ['./customize-dashboard-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -30,6 +30,7 @@ export class CustomizeDashboardStreamsComponent {
private readonly utilityService = inject(UtilityService);
items: DashboardStream[] = [];
allSmartFilters: SmartFilter[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
listForm: FormGroup = new FormGroup({
@ -54,12 +55,19 @@ export class CustomizeDashboardStreamsComponent {
this.accessibilityMode = true;
}
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.allSmartFilters = results[1];
this.updateSmartFilters();
this.cdRef.markForCheck();
});
}
updateSmartFilters() {
const smartFilterStreams = new Set(this.items.filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = this.allSmartFilters.filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();
}
addFilterToStream(filter: SmartFilter) {
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
@ -79,4 +87,17 @@ export class CustomizeDashboardStreamsComponent {
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
}
delete(item: DashboardStream) {
this.dashboardService.deleteSmartFilterStream(item.id).subscribe({
next: () => {
this.items = this.items.filter(d => d.id !== item.id);
this.updateSmartFilters();
this.cdRef.markForCheck();
},
error: (err) => {
console.error(err);
}
});
}
}

View file

@ -37,7 +37,10 @@
[virtualizeAfter]="virtualizeAfter"
>
<ng-template #draggableItem let-position="idx" let-item>
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
<app-sidenav-stream-list-item [item]="item" [position]="position"
(hide)="updateVisibility($event, position)"
(delete)="delete($event)"
/>
</ng-template>
</app-draggable-ordered-list>
</div>

View file

@ -41,6 +41,7 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
public readonly utilityService = inject(UtilityService);
items: SideNavStream[] = [];
allSmartFilters: SmartFilter[] = [];
smartFilters: SmartFilter[] = [];
externalSources: ExternalSource[] = [];
virtualizeAfter = 100;
@ -148,8 +149,8 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
this.pageOperationsForm.get('accessibilityMode')?.setValue(true);
}
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
this.allSmartFilters = results[1];
this.updateSmartFilters();
const existingExternalSourceStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.ExternalSource).map(d => d.name));
this.externalSources = results[2].filter(d => !existingExternalSourceStreams.has(d.name));
@ -157,6 +158,12 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
});
}
updateSmartFilters() {
const existingSmartFilterStreams = new Set(this.items.filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
this.smartFilters = this.allSmartFilters.filter(d => !existingSmartFilterStreams.has(d.name));
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.bulkSelectionService.deselectAll();
}
@ -210,4 +217,17 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
this.sideNavService.updateSideNavStream(stream).subscribe();
}
delete(item: SideNavStream) {
this.sideNavService.deleteSideNavSmartFilter(item.id).subscribe({
next: () => {
this.items = this.items.filter(i => i.id !== item.id);
this.updateSmartFilters();
this.cdRef.markForCheck();
},
error: err => {
console.error(err);
}
})
}
}

View file

@ -14,6 +14,13 @@
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
@if (item.streamType===StreamType.SmartFilter) {
<button class="btn btn-icon" (click)="delete.emit(item)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
}
</span>
</h5>
<div class="meta">

View file

@ -1,12 +1,14 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output} from '@angular/core';
import {APP_BASE_HREF, NgClass} from '@angular/common';
import {APP_BASE_HREF, NgClass, NgIf} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
import {StreamNamePipe} from "../../../_pipes/stream-name.pipe";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
@Component({
selector: 'app-dashboard-stream-list-item',
imports: [TranslocoDirective, StreamNamePipe, NgClass],
imports: [TranslocoDirective, StreamNamePipe, NgClass, NgIf],
templateUrl: './dashboard-stream-list-item.component.html',
styleUrls: ['./dashboard-stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -15,5 +17,7 @@ export class DashboardStreamListItemComponent {
@Input({required: true}) item!: DashboardStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
@Output() delete: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
protected readonly baseUrl = inject(APP_BASE_HREF);
protected readonly StreamType = StreamType;
}

View file

@ -0,0 +1,39 @@
<ng-container *transloco="let t; read:'manage-smart-filters'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{t('edit-smart-filter', {name: smartFilter.name | sentenceCase})}}
</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<form [formGroup]="smartFilterForm">
<div class="m-3">
<label for="smart-filter-title" class="form-label">{{t('name-label')}}</label>
@if (smartFilterForm.get('name'); as nameControl) {
<input id="smart-filter-title" class="form-control" formControlName="name" type="text"
[class.is-invalid]="nameControl.invalid && !nameControl.untouched">
@if (smartFilterForm.dirty || !smartFilterForm.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (nameControl.errors?.required) {
<div>{{t('required-field')}}</div>
}
@if (nameControl.errors?.duplicateName) {
<div>{{t('filter-name-unique')}}</div>
}
</div>
}
}
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="close()">{{t('cancel')}}</button>
<button type="button" class="btn btn-primary" [disabled]="!smartFilterForm.valid" (click)="save()">{{t('save')}}</button>
</div>
</ng-container>

View file

@ -0,0 +1,83 @@
import {ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {TranslocoDirective} from "@jsverse/transloco";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {FilterService} from "../../../_services/filter.service";
import {debounceTime, distinctUntilChanged, switchMap} from "rxjs/operators";
import {of, tap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-edit-smart-filter-modal',
imports: [
TranslocoDirective,
SentenceCasePipe,
ReactiveFormsModule
],
templateUrl: './edit-smart-filter-modal.component.html',
styleUrl: './edit-smart-filter-modal.component.scss'
})
export class EditSmartFilterModalComponent implements OnInit {
private readonly modal = inject(NgbActiveModal);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
@Input({required: true}) smartFilter!: SmartFilter;
@Input({required: true}) allFilters!: SmartFilter[];
smartFilterForm: FormGroup = new FormGroup({
'name': new FormControl('', [Validators.required]),
});
ngOnInit(): void {
this.smartFilterForm.get('name')!.setValue(this.smartFilter.name);
this.smartFilterForm.get('name')!.valueChanges.pipe(
debounceTime(100),
distinctUntilChanged(),
switchMap(name => {
const other = this.allFilters.find(f => {
return f.id !== this.smartFilter.id && f.name === name;
})
return of(other !== undefined)
}),
tap((exists) => {
const isThisSmartFilter = this.smartFilter.name === this.smartFilterForm.get('name')!.value;
const empty = (this.smartFilterForm.get('name')!.value as string).trim().length === 0;
if (!exists || isThisSmartFilter) {
if (!empty) {
this.smartFilterForm.get('name')!.setErrors(null);
}
} else {
this.smartFilterForm.get('name')!.setErrors({duplicateName: true});
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
close(closeVal: boolean = false) {
this.modal.close(closeVal);
}
save() {
this.smartFilter.name = this.smartFilterForm.get('name')!.value;
this.filterService.renameSmartFilter(this.smartFilter).subscribe({
next: () => {
this.modal.close(true);
},
error: () => {
this.modal.close(false);
}
});
}
}

View file

@ -21,10 +21,17 @@
}
<a [href]="baseUrl + 'all-series?' + f.filter" [target]="target">{{f.name}}</a>
</span>
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
<div class="float-end">
<button class="btn btn-actions me-2" (click)="editFilter(f)">
<i class="fa-solid fa-pencil" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit')}}</span>
</button>
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
</div>
</li>
} @empty {
<li class="list-group-item">

View file

@ -1,13 +1,15 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input} from '@angular/core';
import {FilterService} from "../../../_services/filter.service";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {TranslocoDirective} from "@jsverse/transloco";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ActionService} from "../../../_services/action.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {NgbModal, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {RouterLink} from "@angular/router";
import {APP_BASE_HREF} from "@angular/common";
import {EditSmartFilterModalComponent} from "../edit-smart-filter-modal/edit-smart-filter-modal.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-manage-smart-filters',
@ -21,6 +23,8 @@ export class ManageSmartFiltersComponent {
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionService = inject(ActionService);
private readonly destroyRef = inject(DestroyRef);
private readonly modelService = inject(NgbModal);
protected readonly baseUrl = inject(APP_BASE_HREF);
@Input() target: '_self' | '_blank' = '_blank';
@ -63,4 +67,16 @@ export class ManageSmartFiltersComponent {
});
}
editFilter(f: SmartFilter) {
const modalRef = this.modelService.open(EditSmartFilterModalComponent, { size: 'xl', fullscreen: 'md' });
modalRef.componentInstance.smartFilter = f;
modalRef.componentInstance.allFilters = this.filters;
modalRef.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
if (result) {
this.resetFilter();
this.loadData();
}
});
}
}

View file

@ -1,3 +1,4 @@
@if (link === undefined || link.length === 0) {
<div class="side-nav-item" [ngClass]="{'closed': ((navService.sideNavCollapsed$ | async)), 'active': highlighted}">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
@ -26,7 +27,13 @@
<ng-template #inner>
<div class="active-highlight"></div>
@if (!noIcon) {
@if (editMode) {
<span class="phone-hidden" title="{{title}}">
<div>
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true"></i>
</div>
</span>
} @else if (!noIcon) {
<span class="phone-hidden" title="{{title}}">
<div>
@if (imageUrl !== null && imageUrl !== '') {

View file

@ -38,3 +38,7 @@
}
}
}
.drag-handle {
cursor: grab;
}

View file

@ -9,11 +9,11 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
@Component({
selector: 'app-side-nav-item',
imports: [RouterLink, ImageComponent, NgTemplateOutlet, NgClass, AsyncPipe],
templateUrl: './side-nav-item.component.html',
styleUrls: ['./side-nav-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-side-nav-item',
imports: [RouterLink, ImageComponent, NgTemplateOutlet, NgClass, AsyncPipe],
templateUrl: './side-nav-item.component.html',
styleUrls: ['./side-nav-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SideNavItemComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
@ -62,7 +62,13 @@ export class SideNavItemComponent implements OnInit {
*/
@Input() badgeCount: number | null = -1;
/**
* Optional, display item in edit mode (replaces icon with handle)
*/
@Input() editMode: boolean = false;
/**
* Comparison Method for route to determine when to highlight item based on route
*/
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';

View file

@ -3,19 +3,27 @@
<div class="side-nav-container" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false,
'no-donate': (licenseService.hasValidLicense$ | async) === true}">
<div class="side-nav">
<div class="side-nav" cdkDropList (cdkDropListDropped)="reorderDrop($event)">
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v"
(actionHandler)="performHomeAction($event)" />
</ng-container>
</app-side-nav-item>
@if (navStreams$ | async; as streams) {
@if (showAll) {
<app-side-nav-item icon="fa fa-chevron-left" [title]="t('back')" (click)="showLess()"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa fa-chevron-left"
[title]="t(editMode ? 'cancel-edit' : 'back')" (click)="showLess()"></app-side-nav-item>
@if (!isReadOnly) {
<app-side-nav-item icon="fa-cogs" [title]="t('customize')" link="/settings" [fragment]="SettingsTabId.Customize"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-cogs"
[title]="t('customize')" link="/settings"
[fragment]="SettingsTabId.Customize"></app-side-nav-item>
}
@if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false) {
<div class="mb-2 mt-3 ms-2 me-2">
@if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false && !editMode) {
<div cdkDrag cdkDragDisabled class="mb-2 mt-3 ms-2 me-2">
<label for="filter" class="form-label visually-hidden">{{t('filter-label')}}</label>
<div class="form-group position-relative">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
@ -29,8 +37,12 @@
@for (navStream of streams | filter: filterLibrary; track navStream.name + navStream.order) {
@switch (navStream.streamType) {
@case (SideNavStreamType.Library) {
<app-side-nav-item [link]="'/library/' + navStream.libraryId + '/'"
[icon]="getLibraryTypeIcon(navStream.library!.type)" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name" [comparisonMethod]="'startsWith'">
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode"
[link]="'/library/' + navStream.libraryId + '/'"
[icon]="getLibraryTypeIcon(navStream.library!.type)"
[imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name"
[comparisonMethod]="'startsWith'">
<ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
@ -39,35 +51,46 @@
}
@case (SideNavStreamType.AllSeries) {
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
}
@case (SideNavStreamType.Bookmarks) {
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent" [cdkDragDisabled]="!editMode"
[cdkDragData]="navStream" [editMode]="editMode" icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
}
@case (SideNavStreamType.ReadingLists) {
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode"
icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/"></app-side-nav-item>
}
@case (SideNavStreamType.Collections) {
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
}
@case (SideNavStreamType.WantToRead) {
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
}
@case (SideNavStreamType.BrowseAuthors) {
<app-side-nav-item icon="fa-users" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-users" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
}
@case (SideNavStreamType.SmartFilter) {
<app-side-nav-item icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
}
@case (SideNavStreamType.ExternalSource) {
<app-side-nav-item icon="fa-server" [title]="navStream.name" [link]="navStream.externalSource.host + 'login?apiKey=' + navStream.externalSource.apiKey" [external]="true"></app-side-nav-item>
<app-side-nav-item [editMode]="editMode" icon="fa-server"
cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[title]="navStream.name" [link]="navStream.externalSource.host + 'login?apiKey=' + navStream.externalSource.apiKey"
[external]="true"></app-side-nav-item>
}
}

View file

@ -1,70 +1,71 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router';
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators';
import { ImageService } from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { Breakpoint, UtilityService } from '../../../shared/_services/utility.service';
import { Library, LibraryType } from '../../../_models/library/library';
import { AccountService } from '../../../_services/account.service';
import { Action, ActionFactoryService, ActionItem } from '../../../_services/action-factory.service';
import { ActionService } from '../../../_services/action.service';
import { NavService } from '../../../_services/nav.service';
import {ImageService} from 'src/app/_services/image.service';
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {Breakpoint, UtilityService} from '../../../shared/_services/utility.service';
import {Library, LibraryType} from '../../../_models/library/library';
import {AccountService} from '../../../_services/account.service';
import {Action, ActionFactoryService, ActionItem} from '../../../_services/action-factory.service';
import {ActionService} from '../../../_services/action.service';
import {NavService} from '../../../_services/nav.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs";
import {AsyncPipe, NgClass} from "@angular/common";
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {FormsModule} from "@angular/forms";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
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";
import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-side-nav',
imports: [SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, NgbTooltip, NgClass, AsyncPipe],
templateUrl: './side-nav.component.html',
styleUrls: ['./side-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-side-nav',
imports: [SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, NgbTooltip,
NgClass, AsyncPipe, CdkDropList, CdkDrag],
templateUrl: './side-nav.component.html',
styleUrls: ['./side-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SideNavComponent implements OnInit {
private readonly router = inject(Router);
protected readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly actionService = inject(ActionService);
public readonly navService = inject(NavService);
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);
protected readonly WikiLink = WikiLink;
protected readonly ItemLimit = 10;
protected readonly SideNavStreamType = SideNavStreamType;
protected readonly SettingsTabId = SettingsTabId;
protected readonly Breakpoint = Breakpoint;
private readonly router = inject(Router);
protected readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly actionService = inject(ActionService);
protected readonly navService = inject(NavService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly imageService = inject(ImageService);
protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService);
private readonly destroyRef = inject(DestroyRef);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly toastr = inject(ToastrService)
cachedData: SideNavStream[] | null = null;
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
homeActions: ActionItem<void>[] = this.actionFactoryService.getSideNavHomeActions(this.handleHomeAction.bind(this));
filterQuery: string = '';
filterLibrary = (stream: SideNavStream) => {
return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
}
showAll: boolean = false;
editMode: boolean = false;
totalSize = 0;
isReadOnly = false;
@ -88,7 +89,7 @@ export class SideNavComponent implements OnInit {
})
);
navStreams$ = merge(
navStreams$: Observable<SideNavStream[]> = merge(
this.showAll$.pipe(
startWith(false),
distinctUntilChanged(),
@ -178,12 +179,28 @@ export class SideNavComponent implements OnInit {
}
}
async handleHomeAction(action: ActionItem<void>) {
switch (action.action) {
case Action.Edit:
this.showMore(true);
break;
default:
break;
}
}
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);
}
}
performHomeAction(action: ActionItem<void>) {
if (typeof action.callback === 'function') {
action.callback(action)
}
}
getLibraryTypeIcon(format: LibraryType) {
switch (format) {
case LibraryType.Book:
@ -208,15 +225,31 @@ export class SideNavComponent implements OnInit {
this.navService.toggleSideNav();
}
showMore() {
showMore(edit: boolean = false) {
this.showAllSubject.next(true);
this.editMode = edit;
this.cdRef.markForCheck();
}
showLess() {
this.filterQuery = '';
this.cdRef.markForCheck();
this.showAllSubject.next(false);
this.editMode = false;
this.cdRef.markForCheck();
}
protected readonly Breakpoint = Breakpoint;
async reorderDrop($event: CdkDragDrop<any, any, SideNavStream>) {
const stream = $event.item.data;
// Offset the home, back, and customize button
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({
next: () => {
this.showAllSubject.next(this.showAll);
this.cdRef.markForCheck();
},
error: err => {
console.error(err);
this.toastr.error(translate('errors.generic'));
}
});
}
}

View file

@ -9,11 +9,15 @@
}
<span class="float-end">
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
</span>
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
<button *ngIf="item.streamType===SideNavStreamType.SmartFilter" class="btn btn-icon" (click)="delete.emit(item)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
</span>
</h5>
<div class="meta">
<div class="ps-1">

View file

@ -17,6 +17,7 @@ export class SidenavStreamListItemComponent {
@Input({required: true}) item!: SideNavStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
@Output() delete: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
protected readonly SideNavStreamType = SideNavStreamType;
protected readonly baseUrl = inject(APP_BASE_HREF);
}

View file

@ -1,44 +1,72 @@
<ng-container *transloco="let t; read:'typeahead'">
<form [formGroup]="typeaheadForm">
<div class="input-group {{hasFocus ? 'open': ''}} {{locked ? 'lock-active' : ''}}">
<ng-container *ngIf="settings.showLocked">
<span class="input-group-text clickable" (click)="toggleLock($event)"><i class="fa fa-lock" aria-hidden="true"></i>
<span class="visually-hidden">{{t('locked-field')}}</span>
</span>
</ng-container>
<div class="typeahead-input" [ngClass]="{'disabled': disabled}" (click)="onInputFocus($event)">
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index" fillStyle="filled">
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i, value: typeaheadControl.value }"></ng-container>
<i class="fa fa-times" *ngIf="!disabled" (click)="toggleSelection(option)" tabindex="0" [attr.aria-label]="t('close')"></i>
</app-tag-badge>
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead" *ngIf="!disabled">
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status" *ngIf="isLoadingOptions">
<span class="visually-hidden">{{t('loading')}}</span>
@if(settings) {
<form [formGroup]="typeaheadForm">
<div class="input-group {{hasFocus ? 'open': ''}} {{locked ? 'lock-active' : ''}}">
@if (settings.showLocked) {
<span class="input-group-text clickable" (click)="toggleLock($event)"><i class="fa fa-lock" aria-hidden="true"></i>
<span class="visually-hidden">{{t('locked-field')}}</span>
</span>
}
<div class="typeahead-input" [ngClass]="{'disabled': disabled}" (click)="onInputFocus($event)">
@if (optionSelection) {
@for (option of optionSelection.selected(); track option; let i = $index) {
<app-tag-badge fillStyle="filled">
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i, value: typeaheadControl.value }"></ng-container>
@if (!disabled) {
<i class="fa fa-times" (click)="toggleSelection(option)" tabindex="0" [attr.aria-label]="t('close')"></i>
}
</app-tag-badge>
}
}
@if (!disabled) {
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead">
}
@if (isLoadingOptions) {
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
}
@if (!disabled && settings.multiple && (selectedData | async); as selected) {
@if (selected.length > 0) {
<button class="btn btn-close float-end mt-2" style="font-size: 0.8rem;" (click)="clearSelections(true);$event.stopPropagation()"></button>
}
}
</div>
<ng-container *ngIf="!disabled && settings.multiple && (selectedData | async) as selected">
<button class="btn btn-close float-end mt-2" *ngIf="selected.length > 0" style="font-size: 0.8rem;" (click)="clearSelections(true);$event.stopPropagation()"></button>
</ng-container>
</div>
</div>
<ng-container *ngIf="filteredOptions | async as options">
<div class="dropdown" *ngIf="hasFocus" [@slideFromTop]="hasFocus">
<ul class="list-group results" #results>
<li *ngIf="showAddItem"
class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
{{t('add-item', {item: typeaheadControl.value})}}
</li>
<li *ngFor="let option of options; let index = index; trackBy: settings.trackByIdentityFn" (click)="handleOptionClick(option)"
class="list-group-item" role="option" [attr.data-index]="index"
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index, value: typeaheadControl.value }"></ng-container>
</li>
<li *ngIf="options.length === 0 && !showAddItem" class="list-group-item no-hover" role="status">
{{t('no-data')}}{{settings.addIfNonExisting ? t('add-custom-item') : ''}}
</li>
</ul>
</div>
</ng-container>
</form>
@if (filteredOptions | async; as options) {
@if (hasFocus) {
<div class="dropdown" [@slideFromTop]="hasFocus">
<ul class="list-group results" #results>
@if (showAddItem) {
<li class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
{{t('add-item', {item: typeaheadControl.value})}}
</li>
}
@for(option of options; track settings.trackByIdentityFn(index, option); let index = $index) {
<li (click)="handleOptionClick(option)"
class="list-group-item" role="option" [attr.data-index]="index"
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
{{settings.trackByIdentityFn(index, option)}}
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index, value: typeaheadControl.value }"></ng-container>
</li>
}
@if (options.length === 0 && !showAddItem) {
<li class="list-group-item no-hover" role="listitem">
{{t('no-data')}}{{settings.addIfNonExisting ? t('add-custom-item') : ''}}
</li>
}
</ul>
</div>
}
}
</form>
}
</ng-container>

View file

@ -1,10 +1,11 @@
import { trigger, state, style, transition, animate } from '@angular/animations';
import {CommonModule, DOCUMENT} from '@angular/common';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {AsyncPipe, DOCUMENT, NgClass, NgTemplateOutlet} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild, DestroyRef,
ContentChild,
DestroyRef,
ElementRef,
EventEmitter,
HostListener,
@ -19,10 +20,10 @@ import {
ViewChild
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import { Observable, ReplaySubject } from 'rxjs';
import { auditTime, filter, map, shareReplay, switchMap, take, tap } from 'rxjs/operators';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from '../_models/typeahead-settings';
import {Observable, ReplaySubject} from 'rxjs';
import {auditTime, filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators';
import {KEY_CODES} from 'src/app/shared/_services/utility.service';
import {TypeaheadSettings} from '../_models/typeahead-settings';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
@ -32,23 +33,23 @@ import {SelectionModel} from "../_models/selection-model";
const ANIMATION_SPEED = 200;
@Component({
selector: 'app-typeahead',
imports: [CommonModule, TagBadgeComponent, ReactiveFormsModule, TranslocoDirective],
templateUrl: './typeahead.component.html',
styleUrls: ['./typeahead.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('slideFromTop', [
state('in', style({ height: '0px' })),
transition('void => *', [
style({ height: '100%', overflow: 'auto' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ height: '0px' })),
])
])
]
selector: 'app-typeahead',
imports: [TagBadgeComponent, ReactiveFormsModule, TranslocoDirective, AsyncPipe, NgTemplateOutlet, NgClass],
templateUrl: './typeahead.component.html',
styleUrls: ['./typeahead.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('slideFromTop', [
state('in', style({ height: '0px' })),
transition('void => *', [
style({ height: '100%', overflow: 'auto' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ height: '0px' })),
])
])
]
})
export class TypeaheadComponent implements OnInit {
/**
@ -76,7 +77,7 @@ export class TypeaheadComponent implements OnInit {
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
@Output() onUnlock = new EventEmitter<void>();
@Output() lockedChange = new EventEmitter<boolean>();
private readonly destroyRef = inject(DestroyRef);
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
@ -93,7 +94,11 @@ export class TypeaheadComponent implements OnInit {
typeaheadControl!: FormControl;
typeaheadForm!: FormGroup;
constructor(private renderer2: Renderer2, @Inject(DOCUMENT) private document: Document, private readonly cdRef: ChangeDetectorRef) { }
private readonly destroyRef = inject(DestroyRef);
private readonly renderer2 = inject(Renderer2);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(@Inject(DOCUMENT) private document: Document) { }
ngOnInit() {
this.reset.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((resetToEmpty: boolean) => {
@ -118,7 +123,8 @@ export class TypeaheadComponent implements OnInit {
}
if (this.settings.trackByIdentityFn === undefined) {
this.settings.trackByIdentityFn = (index, value) => value;
console.warn('No trackby function provided, falling back to an expensive implementation')
this.settings.trackByIdentityFn = (_, value) => value;
}
if (this.settings.hasOwnProperty('formControl') && this.settings.formControl) {
@ -222,9 +228,9 @@ export class TypeaheadComponent implements OnInit {
}
case KEY_CODES.ENTER:
{
this.document.querySelectorAll('.list-group-item').forEach((item, index) => {
this.document.querySelectorAll('.list-group-item').forEach((item, _) => {
if (item.classList.contains('active')) {
this.filteredOptions.pipe(take(1)).subscribe((opts: any[]) => {
this.filteredOptions.pipe(take(1)).subscribe((_: any[]) => {
// This isn't giving back the filtered array, but everything
event.preventDefault();
event.stopPropagation();
@ -413,7 +419,7 @@ export class TypeaheadComponent implements OnInit {
this.cdRef.markForCheck();
}
toggleLock(event: any) {
toggleLock(_: any) {
if (this.disabled) return;
this.locked = !this.locked;
this.lockedChange.emit(this.locked);

View file

@ -1,5 +1,5 @@
import { Observable } from 'rxjs';
import { FormControl } from '@angular/forms';
import {Observable} from 'rxjs';
import {FormControl} from '@angular/forms';
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
@ -28,7 +28,7 @@ export class TypeaheadSettings<T> {
*/
savedData!: T[] | T;
/**
* Function to compare the elements. Should return all elements that fit the matching criteria.
* Function to compare the elements. Should return all elements that fit the matching criteria.
* This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead.
*/
compareFn!: ((optionList: T[], filter: string) => T[]);
@ -37,12 +37,12 @@ export class TypeaheadSettings<T> {
*/
compareFnForAdd!: ((optionList: T[], filter: string) => T[]);
/**
* Function which is used for comparing objects when keeping track of state.
* Function which is used for comparing objects when keeping track of state.
* Useful over shallow equal when you have image urls that have random numbers on them.
*/
*/
selectionCompareFn?: SelectionCompareFn<T>;
/**
* Function to fetch the data from the server. If data is mainatined in memory, wrap in an observable.
* Function to fetch the data from the server. If data is maintained in memory, wrap in an observable.
*/
fetchFn!: (filter: string) => Observable<T[]>;
/**
@ -50,7 +50,7 @@ export class TypeaheadSettings<T> {
*/
minCharacters: number = 1;
/**
* Optional form Control to tie model to.
* Optional form Control to tie model to.
*/
formControl?: FormControl;
/**
@ -68,5 +68,5 @@ export class TypeaheadSettings<T> {
/**
* An optional, but recommended trackby identity function to help Angular render the list better
*/
trackByIdentityFn!: (index: number, value: T) => T;
}
trackByIdentityFn!: (index: number, value: T) => string;
}

View file

@ -9,12 +9,12 @@
<ng-container>
<div class="row g-0 mt-4 mb-4">
<app-setting-item [title]="t('locale-label')" [subtitle]="t('locale-tooltip')">
<app-setting-item [title]="t('locale-label')" [subtitle]="t('locale-tooltip')" labelId="locale">
<ng-template #view>
{{Locale | titlecase}}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="global-header" formControlName="locale">
<select class="form-select" aria-describedby="global-header" formControlName="locale" id="locale">
@for(opt of locales; track opt.renderName) {
<option [value]="opt.fileName">{{opt.renderName | titlecase}} ({{opt.translationCompletion | number:'1.0-1'}}%)</option>
}
@ -27,7 +27,7 @@
<app-setting-switch [title]="t('blur-unread-summaries-label')" [subtitle]="t('blur-unread-summaries-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="blur-unread-summaries"
formControlName="blurUnreadSummaries" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -40,7 +40,7 @@
<app-setting-switch [title]="t('prompt-on-download-label')" [subtitle]="t('prompt-on-download-tooltip', {size: '100'})">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="prompt-on-download"
formControlName="promptForDownloadSize" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -52,7 +52,7 @@
<app-setting-switch [title]="t('disable-animations-label')" [subtitle]="t('disable-animations-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="disable-animations"
formControlName="noTransitions" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -64,7 +64,7 @@
<app-setting-switch [title]="t('collapse-series-relationships-label')" [subtitle]="t('collapse-series-relationships-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="collapse-series-relationships"
formControlName="collapseSeriesRelationships" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -76,7 +76,7 @@
<app-setting-switch [title]="t('share-series-reviews-label')" [subtitle]="t('share-series-reviews-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="share-series-reviews"
formControlName="shareReviews" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -96,7 +96,8 @@
<app-setting-switch [title]="t('anilist-scrobbling-label')" [subtitle]="t('anilist-scrobbling-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="setting-anilist-scrobbling" type="checkbox" class="form-check-input" formControlName="aniListScrobblingEnabled">
<input id="setting-anilist-scrobbling" type="checkbox"
class="form-check-input" formControlName="aniListScrobblingEnabled">
</div>
</ng-template>
</app-setting-switch>
@ -245,7 +246,7 @@
<app-setting-switch [title]="t('emulate-comic-book-label')" [subtitle]="t('emulate-comic-book-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="emulate-comic-book"
formControlName="emulateBook" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -257,7 +258,7 @@
<app-setting-switch [title]="t('swipe-to-paginate-label')" [subtitle]="t('swipe-to-paginate-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="swipe-to-paginate"
formControlName="swipeToPaginate" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -269,7 +270,7 @@
<app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="allow-auto-webtoon-reader"
formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
@ -286,7 +287,7 @@
<app-setting-switch [title]="t('tap-to-paginate-label')" [subtitle]="t('tap-to-paginate-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
<input type="checkbox" role="switch" id="tap-to-paginate"
formControlName="bookReaderTapToPaginate" class="form-check-input"
aria-labelledby="auto-close-label">
</div>

View file

@ -1074,8 +1074,10 @@
"donate": "Donate",
"donate-tooltip": "You can remove this by subscribing to Kavita+",
"back": "Back",
"cancel-edit": "Cancel Edit",
"more": "More",
"customize": "{{settings.customize}}"
"customize": "{{settings.customize}}",
"edit": "{{common.edit}}"
},
"browse-authors": {
@ -1217,7 +1219,8 @@
"card-detail-layout": {
"total-items": "{{count}} total items",
"jumpkey-count": "{{count}} Series"
"jumpkey-count": "{{count}} Series",
"no-data": "{{common.no-data}}"
},
"card-item": {
@ -1608,13 +1611,13 @@
"change-password-alt": "Change Password {{user}}",
"resend": "Resend",
"setup": "Setup",
"admin": "Admin",
"last-active-header": "Last Active",
"roles-header": "Roles",
"name-header": "Name",
"none": "None",
"never": "Never",
"online-now-tooltip": "Online Now",
"all-libraries": "All Libraries",
"too-many-libraries": "A lot",
"sharing-header": "Sharing",
"no-data": "There are no other users.",
@ -1623,6 +1626,17 @@
"pending-tooltip": "This user has not validated their email"
},
"role-localized-pipe": {
"admin": "Admin",
"download": "Download",
"change-password": "Change Password",
"bookmark": "Bookmark",
"change-restriction": "Change Restriction",
"login": "Login",
"read-only": "Read Only",
"promote": "Promote"
},
"edit-collection-tags": {
"title": "Edit {{collectionName}} Collection",
"required-field": "{{validation.required-field}}",
@ -1739,6 +1753,7 @@
"stream-list-item": {
"remove": "{{common.remove}}",
"delete": "{{common.delete}}",
"load-filter": "Load Filter",
"provided": "Provided",
"smart-filter": "Smart Filter",
@ -2392,7 +2407,8 @@
"save": "{{common.save}}",
"add": "{{common.add}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"smart-filter-title": "{{customize-dashboard-modal.title-smart-filters}}"
},
"customize-sidenav-streams": {
@ -2423,7 +2439,15 @@
"no-data": "No Smart Filters created",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}",
"errored": "There is an encoding error in the filter. You need to recreate it."
"errored": "There is an encoding error in the filter. You need to recreate it.",
"cancel": "{{common.cancel}}",
"close": "{{common.close}}",
"edit": "{{common.edit}}",
"save": "{{common.save}}",
"edit-smart-filter": "Edit {{name}}",
"name-label": "Name",
"required-field": "Smart Filters need a name",
"filter-name-unique": "Smart Filter names must be unique"
},
"edit-external-source-item": {
@ -2509,6 +2533,14 @@
"is-empty": "Is Empty"
},
"log-level-pipe": {
"debug": "Debug",
"information": "Information",
"trace": "Trace",
"warning": "Warning",
"critical": "Critical"
},
"confirm": {
"alert": "Alert",
"confirm": "Confirm",
@ -2723,7 +2755,8 @@
"title": "Actions",
"copy-settings": "Copy Settings From",
"match": "Match",
"match-tooltip": "Match Series with Kavita+ manually"
"match-tooltip": "Match Series with Kavita+ manually",
"reorder": "Reorder"
},
"preferences": {

View file

@ -1,17 +1,50 @@
import {Injectable} from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {HttpClient} from "@angular/common/http";
import {Translation, TranslocoLoader} from "@jsverse/transloco";
import cacheBusting from 'i18n-cache-busting.json'; // allowSyntheticDefaultImports must be true
@Injectable({ providedIn: 'root' })
export class HttpLoader implements TranslocoLoader {
private loadedVersions: { [key: string]: string } = {};
constructor(private http: HttpClient) {}
getTranslation(langPath: string) {
const tokens = langPath.split('/');
const langCode = tokens[tokens.length - 1];
const url = `assets/langs/${langCode}.json?v=${(cacheBusting as { [key: string]: string })[langCode]}`;
console.log('loading locale: ', url);
return this.http.get<Translation>(url);
const currentHash = (cacheBusting as { [key: string]: string })[langCode] || 'en';
// Check if we've loaded this version before
const cachedVersion = this.loadedVersions[langCode];
// If the hash has changed, force a new request and clear local storage cache
if (cachedVersion && cachedVersion !== currentHash) {
console.log(`Translation hash changed for ${langCode}. Clearing cache.`);
this.clearTranslocoCache(langCode);
}
// Store the version we're loading
this.loadedVersions[langCode] = currentHash;
const url = `assets/langs/${langCode}.json?v=${currentHash}`;
console.log('Loading locale:', url);
// Add cache control headers to prevent browser caching
return this.http.get<Translation>(url, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
}
/**
* Clears Transloco cache for a specific language
*/
private clearTranslocoCache(langCode: string): void {
localStorage.removeItem('translocoLang');
localStorage.removeItem('@transloco/translations');
localStorage.removeItem('@transloco/translations/timestamp');
}
}

View file

@ -1,36 +1,25 @@
/// <reference types="@angular/localize" />
import {
APP_INITIALIZER, ApplicationConfig,
importProvidersFrom,
} from '@angular/core';
import { AppComponent } from './app/app.component';
import { NgCircleProgressModule } from 'ng-circle-progress';
import { ToastrModule } from 'ngx-toastr';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppRoutingModule } from './app/app-routing.module';
import { Title, BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
import { HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
import {
provideTransloco, TranslocoConfig,
TranslocoService
} from "@jsverse/transloco";
import {APP_INITIALIZER, ApplicationConfig, importProvidersFrom,} from '@angular/core';
import {AppComponent} from './app/app.component';
import {NgCircleProgressModule} from 'ng-circle-progress';
import {ToastrModule} from 'ngx-toastr';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AppRoutingModule} from './app/app-routing.module';
import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser';
import {JwtInterceptor} from './app/_interceptors/jwt.interceptor';
import {ErrorInterceptor} from './app/_interceptors/error.interceptor';
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
import {provideTransloco, TranslocoConfig, TranslocoService} from "@jsverse/transloco";
import {environment} from "./environments/environment";
import {HttpLoader} from "./httpLoader";
import {
provideTranslocoPersistLang,
} from "@jsverse/transloco-persist-lang";
import {AccountService} from "./app/_services/account.service";
import {switchMap} from "rxjs";
import {provideTranslocoLocale} from "@jsverse/transloco-locale";
import {provideTranslocoPersistTranslations} from "@jsverse/transloco-persist-translations";
import {LazyLoadImageModule} from "ng-lazyload-image";
import {getSaver, SAVER} from "./app/_providers/saver.provider";
import {distinctUntilChanged} from "rxjs/operators";
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
import {provideTranslocoDefaultLocale} from "@jsverse/transloco-locale/lib/transloco-locale.providers";
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
import {HttpLoader} from "./httpLoader";
const disableAnimations = !('animate' in document.documentElement);
@ -145,12 +134,7 @@ bootstrapApplication(AppComponent, {
provideTranslocoPersistTranslations({
loader: HttpLoader,
storage: { useValue: localStorage },
ttl: 604800
}),
provideTranslocoPersistLang({
storage: {
useValue: localStorage,
},
ttl: environment.production ? 129600 : 0 // 1.5 days in seconds for prod
}),
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },

View file

@ -3,7 +3,11 @@
color: var(--input-text-color);
border-color: var(--input-border-color);
&:focus {
// Chevron
--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='none'%20stroke='currentColor'%20stroke-linecap='round'%20stroke-linejoin='round'%20stroke-width='2'%20d='m2%205%206%206%206-6'/%3e%3c/svg%3e");
&:focus {
border-color: var(--input-focused-border-color);
background-color: var(--input-bg-color);
color: var(--input-text-color);

View file

@ -55,6 +55,9 @@
/* Override bootstrap css variables */
--bs-body-bg: #1f2020;
--bs-primary-rgb: var(--primary-color);
--select2-selection-text-color: var(--dropdown-item-hover-text-color);
--select2-option-highlighted-background: var(--dropdown-item-hover-bg-color);
/* Theming colors that performs a gradient for background. Can be disabled else automatically applied based on cover image colors.