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

@ -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

@ -0,0 +1,21 @@
ul {
margin:0;
padding: 0;
li {
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);
span {
cursor: pointer;
}
}
}

View file

@ -0,0 +1,67 @@
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 {Router} from "@angular/router";
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, ReactiveFormsModule, TranslocoDirective, FilterPipe],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageSmartFiltersComponent {
private readonly filterService = inject(FilterService);
private readonly confirmService = inject(ConfirmService);
private readonly router = inject(Router);
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();
}
loadData() {
this.filterService.getAllFilters().subscribe(filters => {
this.filters = filters;
this.cdRef.markForCheck();
});
}
async loadFilter(f: SmartFilter) {
await this.router.navigateByUrl('all-series?' + f.filter);
}
async deleteFilter(f: SmartFilter) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return;
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;
}