Side Nav Redesign (#2310)

This commit is contained in:
Joe Milazzo 2023-10-14 10:07:53 -05:00 committed by GitHub
parent 5c2ebb87cc
commit 00dddaefae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 5971 additions and 572 deletions

View file

@ -0,0 +1,8 @@
export interface CommonStream {
id: number;
name: string;
isProvided: boolean;
order: number;
visible: boolean;
smartFilterEncoded?: string;
}

View file

@ -1,7 +1,8 @@
import {Observable} from "rxjs";
import {StreamType} from "./stream-type.enum";
import {CommonStream} from "../common-stream";
export interface DashboardStream {
export interface DashboardStream extends CommonStream {
id: number;
name: string;
isProvided: boolean;
@ -12,3 +13,5 @@ export interface DashboardStream {
order: number;
visible: boolean;
}

View file

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

View file

@ -0,0 +1,6 @@
export interface ExternalSource {
id: number;
name: string;
host: string;
apiKey: string;
}

View file

@ -0,0 +1,10 @@
export enum SideNavStreamType {
Collections = 1,
ReadingLists = 2,
Bookmarks = 3,
Library = 4,
SmartFilter = 5,
ExternalSource = 6,
AllSeries = 7,
WantToRead = 8,
}

View file

@ -0,0 +1,18 @@
import {SideNavStreamType} from "./sidenav-stream-type.enum";
import {Library, LibraryType} from "../library";
import {CommonStream} from "../common-stream";
import {ExternalSource} from "./external-source";
export interface SideNavStream extends CommonStream {
name: string;
order: number;
libraryId?: number;
isProvided: boolean;
streamType: SideNavStreamType;
library?: Library;
visible: boolean;
smartFilterId: number;
smartFilterEncoded?: string;
externalSource?: ExternalSource;
}

View file

@ -12,18 +12,18 @@ export class DashboardService {
constructor(private httpClient: HttpClient) { }
getDashboardStreams(visibleOnly = true) {
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly);
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'stream/dashboard?visibleOnly=' + visibleOnly);
}
updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse);
return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-position', {streamName, id: dashboardStreamId, fromPosition, toPosition}, TextResonse);
}
updateDashboardStream(stream: DashboardStream) {
return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse);
return this.httpClient.post(this.baseUrl + 'stream/update-dashboard-stream', stream, TextResonse);
}
createDashboardStream(smartFilterId: number) {
return this.httpClient.post<DashboardStream>(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
return this.httpClient.post<DashboardStream>(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
}
}

View file

@ -8,6 +8,7 @@ import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -86,7 +87,11 @@ export enum EVENTS {
/**
* User's dashboard needs to be re-rendered
*/
DashboardUpdate = 'DashboardUpdate'
DashboardUpdate = 'DashboardUpdate',
/**
* User's sidenav needs to be re-rendered
*/
SideNavUpdate = 'SideNavUpdate'
}
export interface Message<T> {
@ -187,6 +192,12 @@ export class MessageHubService {
payload: resp.body as DashboardUpdateEvent
});
});
this.hubConnection.on(EVENTS.SideNavUpdate, resp => {
this.messagesSource.next({
event: EVENTS.SideNavUpdate,
payload: resp.body as SideNavUpdateEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({

View file

@ -1,6 +1,11 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { 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";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
@Injectable({
providedIn: 'root'
@ -27,15 +32,36 @@ export class NavService {
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
private renderer: Renderer2;
baseUrl = environment.apiUrl;
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2) {
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) {
this.renderer = rendererFactory.createRenderer(null, null);
this.showNavBar();
const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false;
this.sideNavCollapseSource.next(sideNavState);
this.showSideNav();
}
getSideNavStreams(visibleOnly = true) {
return this.httpClient.get<Array<SideNavStream>>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly);
}
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse);
}
updateSideNavStream(stream: SideNavStream) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-stream', stream, TextResonse);
}
createSideNavStream(smartFilterId: number) {
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream?smartFilterId=' + smartFilterId, {});
}
createSideNavStreamFromExternalSource(externalSourceId: number) {
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {});
}
/**
* Shows the top nav bar. This should be visible on all pages except the reader.
*/
@ -47,7 +73,7 @@ export class NavService {
}
/**
* Hides the top nav bar.
* Hides the top nav bar.
*/
hideNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px');

View file

@ -9,13 +9,17 @@
::ng-deep .changelog {
h1 {
font-size: 26px;
font-size: 26px;
}
p, ul {
margin-bottom: 0px;
}
img {
max-width: 100% !important;
}
}

View file

@ -89,7 +89,9 @@
<label for="release-year" class="form-label">{{t('release-year-label')}}</label>
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
maxlength="4" minlength="4"
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.pattern">
This must be a valid year greater than 1000 and 4 characters long

View file

@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import {environment} from "../environments/environment";
import {HttpClient} from "@angular/common/http";
import {ExternalSource} from "./_models/sidenav/external-source";
import {TextResonse} from "./_types/text-response";
import {map} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class ExternalSourceService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getExternalSources() {
return this.httpClient.get<Array<ExternalSource>>(this.baseUrl + 'stream/external-sources');
}
createSource(source: ExternalSource) {
return this.httpClient.post<ExternalSource>(this.baseUrl + 'stream/create-external-source', source);
}
updateSource(source: ExternalSource) {
return this.httpClient.post<ExternalSource>(this.baseUrl + 'stream/update-external-source', source);
}
deleteSource(externalSourceId: number) {
return this.httpClient.delete(this.baseUrl + 'stream/delete-external-source?externalSourceId=' + externalSourceId);
}
sourceExists(name: string, host: string, apiKey: string) {
return this.httpClient.get<string>(this.baseUrl + `stream/external-source-exists?host=${encodeURIComponent(host)}&name=${name}&apiKey=${apiKey}`, TextResonse)
.pipe(map(s => s == 'true'));
}
}

View file

@ -443,7 +443,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
get LayoutMode() { return LayoutMode; }
get ReadingDirection() { return ReadingDirection; }
get Breakpoint() { return Breakpoint; }
get FittingOption() { return this.generalSettingsForm.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
get FittingOption() { return this.generalSettingsForm?.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
get ReadingAreaWidth() {
return this.readingArea?.nativeElement.scrollWidth - this.readingArea?.nativeElement.clientWidth;
}

View file

@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'streamName',
standalone: true,
pure: true
})
export class StreamNamePipe implements PipeTransform {
transform(value: string): unknown {
return translate('stream-pipe.' + value);
}
}

View file

@ -15,7 +15,7 @@
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
@ -34,12 +34,12 @@
<input id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<i *ngIf="!accessibilityMode" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
<i *ngIf="!accessibilityMode && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
</div>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>

View file

@ -8,6 +8,7 @@ export interface IndexUpdateEvent {
fromPosition: number;
toPosition: number;
item: any;
fromAccessibilityMode: boolean;
}
export interface ItemRemoveEvent {
@ -35,6 +36,10 @@ export class DraggableOrderedListComponent {
* Parent scroll for virtualize pagination
*/
@Input() parentScroll!: Element | Window;
/**
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
*/
@Input() disabled: boolean = false;
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
@ -52,21 +57,23 @@ export class DraggableOrderedListComponent {
this.orderUpdated.emit({
fromPosition: event.previousIndex,
toPosition: event.currentIndex,
item: this.items[event.currentIndex]
item: this.items[event.currentIndex],
fromAccessibilityMode: false
});
this.cdRef.markForCheck();
}
updateIndex(previousIndex: number, item: any) {
// get the new value of the input
var inputElem = <HTMLInputElement>document.querySelector('#reorder-' + previousIndex);
const inputElem = <HTMLInputElement>document.querySelector('#reorder-' + previousIndex);
const newIndex = parseInt(inputElem.value, 10);
if (previousIndex === newIndex) return;
moveItemInArray(this.items, previousIndex, newIndex);
this.orderUpdated.emit({
fromPosition: previousIndex,
toPosition: newIndex,
item: this.items[newIndex]
item: this.items[newIndex],
fromAccessibilityMode: true
});
this.cdRef.markForCheck();
}

View file

@ -2,8 +2,13 @@
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
img {
max-width: 100%;
::ng-deep .update-body {
img {
max-width: 100%;
}
}

View file

@ -1,29 +1,37 @@
<ng-container *transloco="let t; read: 'customize-dashboard-modal'">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4>
<h4 class="modal-title">{{t('title-' + activeTab)}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-position="idx" let-item>
<app-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-stream-list-item>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</h5>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters">
{{filter.name}}
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 154px;">
<li [ngbNavItem]="TabID.Dashboard">
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>
<ng-template ngbNavContent>
<app-customize-dashboard-streams></app-customize-dashboard-streams>
</ng-template>
</li>
<li class="list-group-item" *ngIf="smartFilters.length === 0">
{{t('no-data')}}
<li [ngbNavItem]="TabID.SideNav">
<a ngbNavLink>{{t(TabID.SideNav)}}</a>
<ng-template ngbNavContent>
<app-customize-sidenav-streams></app-customize-sidenav-streams>
</ng-template>
</li>
<li [ngbNavItem]="TabID.SmartFilters">
<a ngbNavLink>{{t(TabID.SmartFilters)}}</a>
<ng-template ngbNavContent>
<app-manage-smart-filters></app-manage-smart-filters>
</ng-template>
</li>
<li [ngbNavItem]="TabID.ExternalSources">
<a ngbNavLink>{{t(TabID.ExternalSources)}}</a>
<ng-template ngbNavContent>
<app-manage-external-sources></app-manage-external-sources>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">

View file

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

View file

@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@an
import {CommonModule} from '@angular/common';
import {SafeHtmlPipe} from "../../../pipe/safe-html.pipe";
import {TranslocoDirective} from "@ngneat/transloco";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
@ -12,60 +12,43 @@ import {
} from "../../../reading-list/_components/reading-list-item/reading-list-item.component";
import {forkJoin} from "rxjs";
import {FilterService} from "../../../_services/filter.service";
import {StreamListItemComponent} from "../stream-list-item/stream-list-item.component";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {DashboardService} from "../../../_services/dashboard.service";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {CustomizeDashboardStreamsComponent} from "../customize-dashboard-streams/customize-dashboard-streams.component";
import {CustomizeSidenavStreamsComponent} from "../customize-sidenav-streams/customize-sidenav-streams.component";
import {ManageExternalSourcesComponent} from "../manage-external-sources/manage-external-sources.component";
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
enum TabID {
Dashboard = 'dashboard',
SideNav = 'sidenav',
SmartFilters = 'smart-filters',
ExternalSources = 'external-sources'
}
@Component({
selector: 'app-customize-dashboard-modal',
standalone: true,
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent],
imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, DashboardStreamListItemComponent,
NgbNav, NgbNavContent, NgbNavLink, NgbNavItem, NgbNavOutlet, CustomizeDashboardStreamsComponent, CustomizeSidenavStreamsComponent, ManageExternalSourcesComponent, ManageSmartFiltersComponent],
templateUrl: './customize-dashboard-modal.component.html',
styleUrls: ['./customize-dashboard-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeDashboardModalComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
activeTab = TabID.Dashboard;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(public modal: NgbActiveModal) {
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
this.items = results[0];
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();
});
}
addFilterToStream(filter: SmartFilter) {
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items.push(stream);
this.cdRef.detectChanges();
});
}
orderUpdated(event: IndexUpdateEvent) {
this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
updateVisibility(item: DashboardStream, position: number) {
this.items[position].visible = !this.items[position].visible;
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
this.cdRef.markForCheck();
}
public readonly utilityService = inject(UtilityService);
private readonly modal = inject(NgbActiveModal);
protected readonly TabID = TabID;
protected readonly Breakpoint = Breakpoint;
close() {
this.modal.close();
}
}

View file

@ -0,0 +1,31 @@
<ng-container *transloco="let t; read: 'customize-dashboard-streams'">
<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>
</ng-template>
</app-draggable-ordered-list>
<h5>Smart Filters</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>
</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>
</ul>
</ng-container>

View file

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

View file

@ -0,0 +1,77 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
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 "@ngneat/transloco";
import {CommonStream} from "../../../_models/common-stream";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-customize-dashboard-streams',
standalone: true,
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, ReactiveFormsModule, FilterPipe],
templateUrl: './customize-dashboard-streams.component.html',
styleUrls: ['./customize-dashboard-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeDashboardStreamsComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
constructor(public modal: NgbActiveModal) {
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
this.items = results[0];
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();
});
}
addFilterToStream(filter: SmartFilter) {
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items = [...this.items, stream];
this.cdRef.markForCheck();
});
}
orderUpdated(event: IndexUpdateEvent) {
this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe();
}
updateVisibility(item: DashboardStream, position: number) {
this.items[position].visible = !this.items[position].visible;
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
this.cdRef.markForCheck();
}
}

View file

@ -0,0 +1,70 @@
<ng-container *transloco="let t; read: 'customize-sidenav-streams'">
<form [formGroup]="listForm">
<div class="row g-0 mb-3 justify-content-between">
<div class="col-9" *ngIf="items.length >= 3">
<label for="sidenav-stream-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="sidenav-stream-filter" autocomplete="off" class="form-control" formControlName="filterSideNavStream" type="text" aria-describedby="reset-sidenav-stream-input">
<button class="btn btn-outline-secondary" type="button" id="reset-sidenav-stream-input" (click)="resetSideNavFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<div class="col-3">
<div class="form-check form-check-inline" style="margin-top: 35px; margin-left: 10px">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
</div>
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value">
<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>
</ng-template>
</app-draggable-ordered-list>
<h5>{{t('smart-filters-title')}}</h5>
<div class="mb-3" *ngIf="smartFilters.length >= 6">
<label for="smart-filter-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="smart-filter-filter" autocomplete="off" class="form-control" formControlName="filterSmartFilter" type="text" aria-describedby="reset-smart-filter-input">
<button class="btn btn-outline-secondary" type="button" id="reset-smart-filter-input" (click)="resetSmartFilterFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let filter of smartFilters | filter: filterSmartFilters">
{{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>
</ul>
<h5 class="mt-3">{{t('external-sources-title')}}</h5>
<div class="mb-3" *ngIf="externalSources.length >= 6">
<label for="external-source-filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="external-source-filter" autocomplete="off" class="form-control" formControlName="filterSmartFilter" type="text" aria-describedby="reset-external-source-input">
<button class="btn btn-outline-secondary" type="button" id="reset-external-source-input" (click)="resetExternalSourceFilter()">{{t('clear')}}</button>
</div>
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<ul class="list-group filter-list">
<li class="filter list-group-item" *ngFor="let source of externalSources | filter: filterExternalSources">
{{source.host}}
<button class="btn btn-icon" (click)="addExternalSourceToStream(source)">
<i class="fa fa-plus" aria-hidden="true"></i>
{{t('add')}}
</button>
</li>
<li class="list-group-item" *ngIf="externalSources.length === 0">
{{t('no-data-external-source')}}
</li>
</ul>
</form>
</ng-container>

View file

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

View file

@ -0,0 +1,135 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {FilterService} from "../../../_services/filter.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {forkJoin} from "rxjs";
import {
DraggableOrderedListComponent,
IndexUpdateEvent
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {NavService} from "../../../_services/nav.service";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {CommonStream} from "../../../_models/common-stream";
import {TranslocoDirective} from "@ngneat/transloco";
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
import {ExternalSourceService} from "../../../external-source.service";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-customize-sidenav-streams',
standalone: true,
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe],
templateUrl: './customize-sidenav-streams.component.html',
styleUrls: ['./customize-sidenav-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeSidenavStreamsComponent {
items: SideNavStream[] = [];
smartFilters: SmartFilter[] = [];
externalSources: ExternalSource[] = [];
accessibilityMode: boolean = false;
listForm: FormGroup = new FormGroup({
'filterSideNavStream': new FormControl('', []),
'filterSmartFilter': new FormControl('', []),
'filterExternalSource': new FormControl('', []),
});
filterSideNavStreams = (listItem: SideNavStream) => {
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
filterSmartFilters = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterSmartFilter || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
filterExternalSources = (listItem: ExternalSource) => {
const filterVal = (this.listForm.value.filterExternalSource || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
private readonly sideNavService = inject(NavService);
private readonly filterService = inject(FilterService);
private readonly externalSourceService = inject(ExternalSourceService);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(public modal: NgbActiveModal) {
forkJoin([this.sideNavService.getSideNavStreams(false),
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
]).subscribe(results => {
this.items = results[0];
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));
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));
this.cdRef.markForCheck();
});
}
resetSideNavFilter() {
this.listForm.get('filterSideNavStream')?.setValue('');
this.cdRef.markForCheck();
}
resetSmartFilterFilter() {
this.listForm.get('filterSmartFilter')?.setValue('');
this.cdRef.markForCheck();
}
resetExternalSourceFilter() {
this.listForm.get('filterExternalSource')?.setValue('');
this.cdRef.markForCheck();
}
addFilterToStream(filter: SmartFilter) {
this.sideNavService.createSideNavStream(filter.id).subscribe(stream => {
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
this.items = [...this.items, stream];
this.cdRef.markForCheck();
});
}
addExternalSourceToStream(externalSource: ExternalSource) {
this.sideNavService.createSideNavStreamFromExternalSource(externalSource.id).subscribe(stream => {
this.externalSources = this.externalSources.filter(d => d.name !== externalSource.name);
this.items = [...this.items, stream];
this.cdRef.markForCheck();
});
}
updateAccessibilityMode() {
this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck();
}
orderUpdated(event: IndexUpdateEvent) {
this.sideNavService.updateSideNavStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(() => {
if (event.fromAccessibilityMode) {
this.sideNavService.getSideNavStreams(false).subscribe((data) => {
this.items = [...data];
this.cdRef.markForCheck();
})
}
});
}
updateVisibility(item: SideNavStream, position: number) {
const stream = this.items.filter(s => s.id == item.id)[0];
stream.visible = !stream.visible;
this.sideNavService.updateSideNavStream(stream).subscribe();
this.cdRef.markForCheck();
}
}

View file

@ -2,7 +2,12 @@
<div class="row pt-2 g-0 list-item">
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.name}}
<span *ngIf="item.isProvided; else nonProvidedTitle">
{{item.name | streamName }}
</span>
<ng-template #nonProvidedTitle>
{{item.name}}
</ng-template>
<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>

View file

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

View file

@ -0,0 +1,83 @@
<ng-container *transloco="let t; read:'edit-external-source-item'">
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-9 col-sm-9"><h4 id="anilist-token-header">{{source.name || t('title')}}</h4></div>
<div class="col-3 text-end">
<button class="btn btn-primary btn-sm me-1" (click)="toggleViewMode()">
<ng-container *ngIf="isViewMode; else editMode">
<i *ngIf="isViewMode" class="fa-solid fa-pen" aria-hidden="true"></i>
<span class="visually-hidden">
{{t('edit')}}
</span>
</ng-container>
<ng-template #editMode>
{{t('cancel')}}
</ng-template>
</button>
<button class="btn btn-danger btn-sm" (click)="delete()">
<span class="visually-hidden">{{t('delete')}}</span>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<form [formGroup]="formGroup">
<div class="form-group mb-3">
<label for="host">{{t('name-label')}}</label>
<input id="name" class="form-control" formControlName="name" type="text"
[class.is-invalid]="formGroup.get('name')?.invalid && formGroup.get('name')?.touched" aria-describedby="name-validations">
<div id="name-validations" class="invalid-feedback" *ngIf="hasErrors('name')">
<div *ngIf="formGroup.get('name')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="formGroup.get('name')?.errors?.notUnique">
{{t('not-unique')}}
</div>
</div>
</div>
<div class="form-group mb-3">
<label for="host">{{t('host-label')}}</label>
<input id="host" class="form-control" formControlName="host" type="url"
[class.is-invalid]="formGroup.get('host')?.invalid && formGroup.get('host')?.touched" aria-describedby="host-validations">
<ng-container *ngIf="formGroup.get('host')?.errors as errors">
<div id="host-validations" class="invalid-feedback">
<div *ngIf="errors.required">
{{t('required')}}
</div>
<div *ngIf="errors.pattern">
{{t('pattern')}}
</div>
<div *ngIf="errors.notUnique">
{{t('not-unique')}}
</div>
</div>
</ng-container>
</div>
<div class="form-group mb-3">
<label for="api-key">{{t('api-key-label')}}</label>
<input id="api-key" class="form-control" formControlName="apiKey" type="text"
[class.is-invalid]="formGroup.get('apiKey')?.invalid && formGroup.get('apiKey')?.touched" aria-describedby="api-key-validations">
<div id="api-key-validations" class="invalid-feedback" *ngIf="hasErrors('apiKey')">
<div *ngIf="formGroup.get('apiKey')?.errors?.required">
{{t('required')}}
</div>
</div>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">{{t('save')}}</button>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,107 @@
import {ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from '@angular/core';
import { CommonModule } from '@angular/common';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {NgbCollapse} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ExternalSourceService} from "../../../external-source.service";
import {distinctUntilChanged, filter, tap} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {switchMap} from "rxjs";
import {ToastrModule, ToastrService} from "ngx-toastr";
@Component({
selector: 'app-edit-external-source-item',
standalone: true,
imports: [CommonModule, NgbCollapse, ReactiveFormsModule, TranslocoDirective],
templateUrl: './edit-external-source-item.component.html',
styleUrls: ['./edit-external-source-item.component.scss']
})
export class EditExternalSourceItemComponent implements OnInit {
@Input({required: true}) source!: ExternalSource;
@Output() sourceUpdate = new EventEmitter<ExternalSource>();
@Output() sourceDelete = new EventEmitter<ExternalSource>();
@Input() isViewMode: boolean = true;
formGroup: FormGroup = new FormGroup({});
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly externalSourceService = inject(ExternalSourceService);
private readonly toastr = inject(ToastrService);
hasErrors(controlName: string) {
const errors = this.formGroup.get(controlName)?.errors;
return Object.values(errors || []).filter(v => v).length > 0;
}
constructor() {}
ngOnInit(): void {
this.formGroup.addControl('name', new FormControl(this.source.name, [Validators.required]));
this.formGroup.addControl('host', new FormControl(this.source.host, [Validators.required, Validators.pattern(/^(http:|https:)+[^\s]+[\w]\/?$/)]));
this.formGroup.addControl('apiKey', new FormControl(this.source.apiKey, [Validators.required]));
this.cdRef.markForCheck();
}
resetForm() {
this.formGroup.get('host')?.setValue(this.source.host);
this.formGroup.get('name')?.setValue(this.source.name);
this.formGroup.get('apiKey')?.setValue(this.source.apiKey);
this.cdRef.markForCheck();
}
saveForm() {
if (this.source === undefined) return;
const model = this.formGroup.value;
this.externalSourceService.sourceExists(model.host, model.name, model.apiKey).subscribe(exists => {
if (exists) {
this.toastr.error(translate('toasts.external-source-already-exists'));
return;
}
if (this.source.id === 0) {
// We need to create a new one
this.externalSourceService.createSource({id: 0, ...this.formGroup.value}).subscribe((updatedSource) => {
this.source = {...updatedSource};
this.sourceUpdate.emit(this.source);
this.toggleViewMode();
});
return;
}
this.externalSourceService.updateSource({id: this.source.id, ...this.formGroup.value}).subscribe((updatedSource) => {
this.source!.host = this.formGroup.value.host;
this.source!.apiKey = this.formGroup.value.apiKey;
this.source!.name = this.formGroup.value.name;
this.sourceUpdate.emit(this.source);
this.toggleViewMode();
});
});
}
delete() {
if (this.source.id === 0) {
this.sourceDelete.emit(this.source);
if (!this.isViewMode) {
this.toggleViewMode();
}
return;
}
this.externalSourceService.deleteSource(this.source.id).subscribe(() => {
this.sourceDelete.emit(this.source);
if (!this.isViewMode) {
this.toggleViewMode();
}
});
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
if (!this.isViewMode) {
this.resetForm();
}
}
}

View file

@ -0,0 +1,28 @@
<ng-container *transloco="let t; read: 'manage-external-sources'">
<p>
{{t('description')}}
<a href="https://wiki.kavitareader.com/en/guides/customization/external-sources" target="_blank" rel="noopener noreferrer">{{t('help-link')}}</a>
</p>
<form class="row g-0 justify-content-between mb-3" [formGroup]="listForm">
<div class="col-9">
<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 class="col-2 ms-2">
<button class="btn btn-primary" style="margin-top: 30px" (click)="addNewExternalSource()">{{t('add-source')}}</button>
</div>
</form>
<ng-container *ngFor="let externalSource of externalSources | filter: filterList; let idx = index">
<app-edit-external-source-item [source]="externalSource"
(sourceUpdate)="updateSource(idx, $event)"
(sourceDelete)="deleteSource(idx, $event)"
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
</ng-container>
</ng-container>

View file

@ -0,0 +1,66 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {NgbCollapse, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {AccountService} from "../../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {EditExternalSourceItemComponent} from "../edit-external-source-item/edit-external-source-item.component";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {ExternalSourceService} from "../../../external-source.service";
import {FilterPipe} from "../../../pipe/filter.pipe";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
@Component({
selector: 'app-manage-external-sources',
standalone: true,
imports: [CommonModule, FormsModule, NgOptimizedImage, NgbTooltip, ReactiveFormsModule, TranslocoDirective, NgbCollapse, EditExternalSourceItemComponent, FilterPipe],
templateUrl: './manage-external-sources.component.html',
styleUrls: ['./manage-external-sources.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageExternalSourcesComponent {
externalSources: Array<ExternalSource> = [];
private readonly cdRef = inject(ChangeDetectorRef);
private readonly externalSourceService = inject(ExternalSourceService);
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: ExternalSource) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0 || listItem.host.toLowerCase().indexOf(filterVal) >= 0;
}
constructor(public accountService: AccountService) {
this.externalSourceService.getExternalSources().subscribe(data => {
this.externalSources = data;
this.cdRef.markForCheck();
});
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
addNewExternalSource() {
this.externalSources.unshift({id: 0, name: '', host: '', apiKey: ''});
this.cdRef.markForCheck();
}
updateSource(index: number, updatedSource: ExternalSource) {
this.externalSources[index] = updatedSource;
this.cdRef.markForCheck();
}
deleteSource(index: number, updatedSource: ExternalSource) {
this.externalSources.splice(index, 1);
this.resetFilter();
this.cdRef.markForCheck();
}
}

View file

@ -0,0 +1,25 @@
<ng-container *transloco="let t; read:'manage-smart-filters'">
<form [formGroup]="listForm">
<div class="mb-3" *ngIf="filters.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>
</div>
</div>
</form>
<ul>
<li class="list-group-item" *ngFor="let f of filters | filter: filterList">
<a [href]="'all-series?' + f.filter" target="_blank">{{f.name}}</a>
<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>
</li>
<li class="list-group-item" *ngIf="filters.length === 0">
{{t('no-data')}}
</li>
</ul>
</ng-container>

View file

@ -1,16 +1,18 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FilterService} from "../../_services/filter.service";
import {SmartFilter} from "../../_models/metadata/v2/smart-filter";
import {FilterService} from "../../../_services/filter.service";
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {Router} from "@angular/router";
import {ConfirmService} from "../../shared/confirm.service";
import {translate} from "@ngneat/transloco";
import {ConfirmService} from "../../../shared/confirm.service";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-manage-smart-filters',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, FilterPipe],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -23,6 +25,19 @@ export class ManageSmartFiltersComponent {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
filters: Array<SmartFilter> = [];
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
resetFilter() {
this.listForm.get('filterQuery')?.setValue('');
this.cdRef.markForCheck();
}
constructor() {
this.loadData();
@ -44,6 +59,7 @@ export class ManageSmartFiltersComponent {
this.filterService.deleteFilter(f.id).subscribe(() => {
this.toastr.success(translate('toasts.smart-filter-deleted'));
this.resetFilter();
this.loadData();
});
}

View file

@ -11,9 +11,16 @@
</a>
</ng-container>
<ng-template #internal>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
<ng-container *ngIf="queryParams && queryParams !== {}; else regInternalLink">
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" (click)="openLink()">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-container>
<ng-template #regInternalLink>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link" [queryParams]="queryParams">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-template>
</ng-template>
</ng-template>

View file

@ -41,6 +41,11 @@ export class SideNavItemComponent implements OnInit {
* If external, link will be used as full href and rel will be applied
*/
@Input() external: boolean = false;
/**
* If using a link, then you can pass optional queryParameters
*/
@Input() queryParams: any | undefined = undefined;
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';
private readonly destroyRef = inject(DestroyRef);
@ -54,8 +59,9 @@ export class SideNavItemComponent implements OnInit {
takeUntilDestroyed(this.destroyRef),
map(evt => evt as NavigationEnd))
.subscribe((evt: NavigationEnd) => {
this.updateHighlight(evt.url.split('?')[0]);
const tokens = evt.url.split('?');
const [token1, token2 = undefined] = tokens;
this.updateHighlight(token1, token2);
});
}
@ -66,23 +72,31 @@ export class SideNavItemComponent implements OnInit {
}
updateHighlight(page: string) {
updateHighlight(page: string, queryParams?: string) {
if (this.link === undefined) {
this.highlighted = false;
this.cdRef.markForCheck();
return;
}
if (!page.endsWith('/')) {
if (!page.endsWith('/') && !queryParams) {
page = page + '/';
}
if (this.comparisonMethod === 'equals' && page === this.link) {
this.highlighted = true;
this.cdRef.markForCheck();
return;
}
if (this.comparisonMethod === 'startsWith' && page.startsWith(this.link)) {
if (queryParams && queryParams === this.queryParams) {
this.highlighted = true;
this.cdRef.markForCheck();
return;
}
this.highlighted = true;
this.cdRef.markForCheck();
return;
@ -92,4 +106,12 @@ export class SideNavItemComponent implements OnInit {
this.cdRef.markForCheck();
}
openLink() {
if (Object.keys(this.queryParams).length === 0) {
this.router.navigateByUrl(this.link!);
return
}
this.router.navigateByUrl(this.link + '?' + this.queryParams);
}
}

View file

@ -1,38 +1,74 @@
<ng-container *transloco="let t; read: 'side-nav'">
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">-->
<!-- <ng-container actions>-->
<!-- <a href="/preferences/" title="User Settings"><span class="visually-hidden">User Settings</span></a>-->
<!-- </ng-container>-->
<!-- </app-side-nav-item>-->
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/libraries/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
<ng-container actions>
<app-card-actionables [actions]="readingListActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false">
<ng-container *ngIf="navStreams$ | async as streams">
<ng-container *ngIf="showAll">
<app-side-nav-item icon="fa fa-chevron-left" [title]="t('back')" (click)="showLess()"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="streams.length > 10 && (navService?.sideNavCollapsed$ | async) === false">
<label for="filter" class="form-label visually-hidden">{{t('filter-label')}}</label>
<div class="form-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button type="button" [attr.aria-label]="t('clear')" class="btn-close" id="reset-input" (click)="filterQuery = '';"></button>
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button type="button" [attr.aria-label]="t('clear')" class="btn-close" id="reset-input" (click)="filterQuery = '';"></button>
</div>
</div>
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id + '/'"
[icon]="getLibraryTypeIcon(library.type)" [imageUrl]="getLibraryImage(library)" [title]="library.name" [comparisonMethod]="'startsWith'">
<ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
</div>
</ng-container>
<ng-container *ngFor="let navStream of streams | filter: filterLibrary">
<ng-container [ngSwitch]="navStream.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">
<app-side-nav-item [link]="'/library/' + navStream.libraryId + '/'"
[icon]="getLibraryTypeIcon(navStream.library!.type)" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.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>
</ng-container>
</app-side-nav-item>
</ng-container>
</app-side-nav-item>
<ng-container *ngSwitchCase="SideNavStreamType.AllSeries">
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.Bookmarks">
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ReadingLists">
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
<ng-container actions>
<app-card-actionables [actions]="readingListActions" [labelBy]="t('reading-lists')" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.Collections">
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.WantToRead">
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">
<app-side-nav-item icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
</ng-container>
<ng-container *ngSwitchCase="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>
</ng-container>
</ng-container>
</ng-container>
<ng-container *ngIf="totalSize > 10 && !showAll">
<app-side-nav-item icon="fa fa-chevron-right" [title]="t('more')" (click)="showMore()"></app-side-nav-item>
</ng-container>
</ng-container>
</div>
<div class="side-nav-overlay" (click)="toggleNavBar()" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async)}"></div>
<div class="bottom" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),

View file

@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {filter, map, shareReplay, take} from 'rxjs/operators';
import {distinctUntilChanged, filter, map, shareReplay, take, tap} from 'rxjs/operators';
import { ImportCblModalComponent } from 'src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component';
import { ImageService } from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
@ -20,7 +20,7 @@ import { ActionService } from '../../../_services/action.service';
import { LibraryService } from '../../../_services/library.service';
import { NavService } from '../../../_services/nav.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {switchMap} from "rxjs";
import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs";
import {CommonModule} from "@angular/common";
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
import {FilterPipe} from "../../../pipe/filter.pipe";
@ -29,6 +29,8 @@ import {TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe";
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
@Component({
selector: 'app-side-nav',
@ -41,19 +43,78 @@ import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/cus
export class SideNavComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly actionFactoryService = inject(ActionFactoryService);
libraries: Library[] = [];
actions: ActionItem<Library>[] = [];
cachedData: SideNavStream[] | null = null;
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
filterQuery: string = '';
filterLibrary = (library: Library) => {
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
}
constructor(private libraryService: LibraryService,
filterQuery: string = '';
filterLibrary = (stream: SideNavStream) => {
return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
}
showAll: boolean = false;
totalSize = 0;
protected readonly SideNavStreamType = SideNavStreamType;
private showAllSubject = new BehaviorSubject<boolean>(false);
showAll$ = this.showAllSubject.asObservable();
private loadDataSubject = new ReplaySubject<void>();
loadData$ = this.loadDataSubject.asObservable();
loadDataOnInit$: Observable<SideNavStream[]> = this.loadData$.pipe(
switchMap(() => {
if (this.cachedData != null) {
return of(this.cachedData);
}
return this.navService.getSideNavStreams().pipe(
map(data => {
this.cachedData = data; // Cache the data after initial load
return data;
})
);
})
);
navStreams$ = merge(
this.showAll$.pipe(
startWith(false),
distinctUntilChanged(),
tap(showAll => this.showAll = showAll),
switchMap(showAll =>
showAll
? this.loadDataOnInit$.pipe(
tap(d => this.totalSize = d.length),
)
: this.loadDataOnInit$.pipe(
tap(d => this.totalSize = d.length),
map(d => d.slice(0, 10))
)
),
takeUntilDestroyed(this.destroyRef),
), this.messageHub.messages$.pipe(
filter(event => event.event === EVENTS.LibraryModified || event.event === EVENTS.SideNavUpdate),
tap(() => {
this.cachedData = null; // Reset cached data to null to get latest
}),
switchMap(() => {
if (this.showAll) return this.loadDataOnInit$;
else return this.loadDataOnInit$.pipe(map(d => d.slice(0, 10)))
}), // Reload data when events occur
takeUntilDestroyed(this.destroyRef),
)
).pipe(
startWith(null),
filter(data => data !== null),
takeUntilDestroyed(this.destroyRef),
);
constructor(
public utilityService: UtilityService, private messageHub: MessageHubService,
private actionFactoryService: ActionFactoryService, private actionService: ActionService,
private actionService: ActionService,
public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef,
private ngbModal: NgbModal, private imageService: ImageService, public readonly accountService: AccountService) {
@ -74,20 +135,7 @@ export class SideNavComponent implements OnInit {
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return;
this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => {
this.libraries = libraries;
this.cdRef.markForCheck();
});
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.cdRef.markForCheck();
});
// TODO: Investigate this, as it might be expensive
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => {
this.libraryService.getLibraries().pipe(take(1), shareReplay()).subscribe((libraries: Library[]) => {
this.libraries = [...libraries];
this.cdRef.markForCheck();
});
this.loadDataSubject.next();
});
}
@ -112,10 +160,8 @@ export class SideNavComponent implements OnInit {
handleHomeActions() {
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
// TODO: If on /, then refresh the page layout
}
importCbl() {
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
}
@ -141,8 +187,17 @@ export class SideNavComponent implements OnInit {
return null;
}
toggleNavBar() {
this.navService.toggleSideNav();
}
showMore() {
this.showAllSubject.next(true);
}
showLess() {
this.showAllSubject.next(false);
}
}

View file

@ -0,0 +1,45 @@
<ng-container *transloco="let t; read: 'stream-list-item'">
<div class="row pt-2 g-0 list-item">
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
<span *ngIf="item.isProvided; else nonProvidedTitle">
{{item.name | streamName }}
</span>
<ng-template #nonProvidedTitle>
{{item.name}}
</ng-template>
<span class="float-end">
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove')}}</span>
</button>
</span>
</h5>
<div class="meta">
<div class="ps-1">
<ng-container *ngIf="item.isProvided; else nonProvided">{{t('provided')}}</ng-container>
<ng-template #nonProvided>
<ng-container [ngSwitch]="item.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">{{t('library')}}</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">{{t('smart-filter')}}</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ExternalSource">{{t('external-source')}}</ng-container>
</ng-container>
</ng-template>
</div>
<div class="ps-1" *ngIf="!item.isProvided">
<ng-container [ngSwitch]="item.streamType">
<ng-container *ngSwitchCase="SideNavStreamType.Library">
<a [href]="'/library/' + this.item.libraryId" target="_blank">{{item.library?.name}}</a>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.SmartFilter">
<a [href]="'/all-series?' + this.item.smartFilterEncoded" target="_blank">{{t('load-filter')}}</a>
</ng-container>
<ng-container *ngSwitchCase="SideNavStreamType.ExternalSource">
<a [href]="item.externalSource!.host! + 'login?apiKey=' + item.externalSource!.apiKey" target="_blank" rel="noopener noreferrer">{{item.externalSource!.host!}}</a>
</ng-container>
</ng-container>
</div>
</div>
</div>
</div>
</ng-container>

View file

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

View file

@ -0,0 +1,21 @@
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {StreamNamePipe} from "../../../pipe/stream-name.pipe";
import {TranslocoDirective} from "@ngneat/transloco";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
@Component({
selector: 'app-sidenav-stream-list-item',
standalone: true,
imports: [CommonModule, StreamNamePipe, TranslocoDirective],
templateUrl: './sidenav-stream-list-item.component.html',
styleUrls: ['./sidenav-stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SidenavStreamListItemComponent {
@Input({required: true}) item!: SideNavStream;
@Input({required: true}) position: number = 0;
@Output() hide: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
protected readonly SideNavStreamType = SideNavStreamType;
}

View file

@ -12,7 +12,7 @@
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
</div>
</div>
<div class="list-group">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
@ -20,7 +20,7 @@
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
</li>
</div>
</ul>
</form>
</div>
<div class="modal-footer">

View file

@ -4,8 +4,8 @@
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey [type]="InputType" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<button class="btn btn-outline-secondary" type="button" (click)="show()" [title]="t('show')" *ngIf="hideData">
<span class="visually-hidden">t('show')</span><i class="fa fa-eye" aria-hidden="true"></i>
<button class="btn btn-outline-secondary" type="button" (click)="toggleVisibility()" [title]="isDataHidden ? t('show') : t('hide')" *ngIf="hideData">
<span class="visually-hidden">{{isDataHidden ? t('show') : t('hide')}}</span><i class="fa {{isDataHidden ? 'fa-eye' : 'fa-eye-slash'}}" aria-hidden="true"></i>
</button>
<button class="btn btn-outline-secondary" type="button" (click)="copy()" [title]="t('copy')">
<span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i>

View file

@ -35,8 +35,10 @@ export class ApiKeyComponent implements OnInit {
key: string = '';
private readonly destroyRef = inject(DestroyRef);
isDataHidden: boolean = this.hideData;
get InputType() {
return this.hideData ? 'password' : 'text';
return (this.hideData && this.isDataHidden) ? 'password' : 'text';
}
constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService, private clipboard: Clipboard,
@ -83,8 +85,8 @@ export class ApiKeyComponent implements OnInit {
}
}
show() {
this.inputElem.nativeElement.setAttribute('type', 'text');
toggleVisibility() {
this.isDataHidden = !this.isDataHidden;
this.cdRef.markForCheck();
}

View file

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

View file

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

View file

@ -48,8 +48,7 @@ import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbAccor
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {LocalizationService} from "../../_services/localization.service";
import {Language} from "../../_models/metadata/language";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component";
import {translate, TranslocoDirective} from "@ngneat/transloco";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -64,9 +63,7 @@ enum FragmentID {
Theme = 'theme',
Devices = 'devices',
Stats = 'stats',
SmartFilters = 'smart-filters',
Scrobbling = 'scrobbling'
}
@Component({
@ -79,7 +76,7 @@ enum FragmentID {
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective, ManageSmartFiltersComponent]
TranslocoDirective]
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -110,7 +107,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
{title: 'theme-tab', fragment: FragmentID.Theme},
{title: 'devices-tab', fragment: FragmentID.Devices},
{title: 'smart-filters-tab', fragment: FragmentID.SmartFilters},
{title: 'stats-tab', fragment: FragmentID.Stats},
];
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];

View file

@ -33,7 +33,7 @@
"user-scrobble-history": {
"title": "Scrobble History",
"description": "Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"created-header": "Created",
"last-modified-header": "Last Modified",
"type-header": "Type",
@ -270,6 +270,7 @@
"api-key": {
"copy": "Copy",
"show": "Show",
"hide": "Hide",
"regen-warning": "Regenerating your API key will invalidate any existing clients.",
"no-key": "ERROR - KEY NOT SET",
"confirm-reset": "This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?",
@ -301,8 +302,8 @@
"generic-list-modal": {
"close": "{{common.close}}",
"clear": "Clear",
"filter": "Filter",
"clear": "{{common.clear}}",
"filter": "{{common.filter}}",
"open-filtered-search": "Open a filtered search for {{item}}"
},
@ -756,10 +757,12 @@
"collections": "Collections",
"reading-lists": "Reading Lists",
"bookmarks": "Bookmarks",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"all-series": "All Series",
"clear": "Clear",
"donate": "Donate"
"clear": "{{common.clear}}",
"donate": "Donate",
"back": "Back",
"more": "More"
},
"library-settings-modal": {
@ -994,7 +997,7 @@
"title": "Add to Collection",
"promoted": "{{common.promoted}}",
"close": "{{common.close}}",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear": "{{common.clear}}",
"no-data": "No collections created yet",
"loading": "{{common.loading}}",
@ -1019,7 +1022,7 @@
"manage-alerts": {
"description-part-1": "This table contains issues found during scan or reading of your media. This list is non-managed. You can clear it at any time and use Library (Force) Scan to perform analysis. A list of some common errors and what they mean can be found on the ",
"description-part-2": "wiki.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear-alerts": "Clear Alerts",
"extension-header": "Extension",
"file-header": "File",
@ -1087,7 +1090,7 @@
"manage-scrobble-errors": {
"description": "This table contains issues found during scrobbling. This list is non-managed. You can clear it at any time and wait for the next scrobble upload to see. If there is an unknown series, you are best correcting the series name or localized series name or adding a weblink for the providers.",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"clear-errors": "Clear Errors",
"series-header": "Series",
"created-header": "Created",
@ -1332,7 +1335,9 @@
"remove": "{{common.remove}}",
"load-filter": "Load Filter",
"provided": "Provided",
"smart-filter": "Smart Filter"
"smart-filter": "Smart Filter",
"library": "Library",
"external-source": "External Source"
},
"reading-list-detail": {
@ -1404,7 +1409,7 @@
"add-to-list-modal": {
"title": "Add to Reading List",
"close": "{{common.close}}",
"filter-label": "Filter",
"filter-label": "{{common.filter}}",
"promoted-alt": "Promoted",
"no-data": "No lists created yet",
"loading": "{{common.loading}}",
@ -1505,7 +1510,7 @@
},
"metadata-filter": {
"filter-title": "Filter",
"filter-title": "{{common.filter}}",
"sort-by-label": "Sort By",
"filter-name-label": "Filter Name",
"ascending-alt": "Ascending",
@ -1737,11 +1742,77 @@
},
"customize-dashboard-modal": {
"title": "Customize Dashboard",
"no-data": "All Smart filters added to Dashboard or none created yet.",
"title-dashboard": "Customize Dashboard",
"title-sidenav": "Customize Side Nav",
"title-external-sources": "External Sources",
"title-smart-filters": "Smart Filters",
"close": "{{common.close}}",
"dashboard": "Dashboard",
"sidenav": "Side Nav",
"external-sources": "External Sources",
"smart-filters": "Smart Filters"
},
"customize-dashboard-streams": {
"no-data": "All Smart filters added to Dashboard or none created yet.",
"save": "{{common.save}}",
"add": "{{common.add}}"
"add": "{{common.add}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
},
"customize-sidenav-streams": {
"no-data": "All Smart filters added to Side Nav or none created yet.",
"no-data-external-source": "All External Sources added to Side Nav or none created yet.",
"save": "{{common.save}}",
"add": "{{common.add}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}",
"smart-filters-title": "Smart Filters",
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}"
},
"manage-external-sources": {
"add-source": "Add",
"help-link": "More information",
"description": "Add External Servers to your account and then add them to your Side Nav for a quick way to switch between your and your friend's server.",
"clear": "{{common.clear}}",
"filter": "{{common.filter}}"
},
"manage-smart-filters": {
"delete": "{{common.delete}}",
"no-data": "No Smart Filters created",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
},
"edit-external-source-item": {
"not-unique": "External source exists with this host. Ensure you don't have duplicates",
"title": "New External Source",
"host-label": "Host",
"name-label": "Name",
"api-key-label": "API Key",
"save": "{{common.save}}",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"delete": "{{common.delete}}",
"pattern": "Host must be a valid http(s):// url",
"required": "{{validation.required-field}}"
},
"stream-pipe": {
"on-deck": "{{dashboard.on-deck-title}}",
"recently-updated": "{{dashboard.recently-updated-title}}",
"newly-added": "{{dashboard.recently-added-title}}",
"more-in-genre": "{{dashboard.more-in-genre-title}}",
"want-to-read": "{{side-nav.want-to-read}}",
"collections": "{{side-nav.collections}}",
"reading-lists": "{{side-nav.reading-lists}}",
"bookmarks": "{{side-nav.bookmarks}}",
"all-series": "{{side-nav.all-series}}"
},
"filter-field-pipe": {
@ -1875,7 +1946,8 @@
"list-doesnt-exist": "This list doesn't exist",
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
"smart-filter-deleted": "Smart Filter Deleted",
"smart-filter-updated": "Created/Updated smart filter"
"smart-filter-updated": "Created/Updated smart filter",
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key"
},
"actionable": {
@ -1900,7 +1972,7 @@
"read-incognito": "Read Incognito",
"details": "Details",
"view-series": "View Series",
"clear": "Clear",
"clear": "{{common.clear}}",
"import-cbl": "Import CBL",
"read": "Read",
"add-rule-group-and": "Add Rule Group (AND)",
@ -1950,6 +2022,8 @@
"common": {
"reset-to-default": "Reset to Default",
"close": "Close",
"clear": "Clear",
"filter": "Filter",
"cancel": "Cancel",
"create": "Create",
"save": "Save",