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'}];