Smart Filter UX (#3768)

This commit is contained in:
Joe Milazzo 2025-04-23 18:08:42 -06:00 committed by GitHub
parent 06a6d9e03b
commit 9ee5821cb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 95 additions and 57 deletions

View File

@ -870,6 +870,14 @@ export class ActionFactoryService {
];
this.smartFilterActions = [
{
action: Action.Edit,
title: 'rename',
description: 'rename-tooltip',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.Delete,
title: 'delete',

View File

@ -9,9 +9,7 @@
<span>{{t('count', {count: filters.length | number})}}</span>
<a class="ms-2" href="/all-series?name=New%20Filter">{{t('create')}}</a>
</h6>
</div>
</app-side-nav-companion-bar>
<app-manage-smart-filters [target]="'_self'"></app-manage-smart-filters>

View File

@ -9,7 +9,7 @@ import {FilterService} from "../_services/filter.service";
import {Router} from "@angular/router";
import {Series} from "../_models/series";
import {JumpbarService} from "../_services/jumpbar.service";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {ActionFactoryService} from "../_services/action-factory.service";
import {ActionService} from "../_services/action.service";
import {ManageSmartFiltersComponent} from "../sidenav/_components/manage-smart-filters/manage-smart-filters.component";
import {DecimalPipe} from "@angular/common";
@ -29,7 +29,7 @@ export class AllFiltersComponent implements OnInit {
private readonly actionFactory = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
filterActions = this.actionFactory.getSmartFilterActions(this.handleAction.bind(this));
jumpbarKeys: Array<JumpKey> = [];
filters: SmartFilter[] = [];
isLoading = true;
@ -46,22 +46,4 @@ export class AllFiltersComponent implements OnInit {
this.cdRef.markForCheck();
});
}
async deleteFilter(filter: SmartFilter) {
await this.actionService.deleteFilter(filter.id, success => {
this.filters = this.filters.filter(f => f.id != filter.id);
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name);
this.cdRef.markForCheck();
});
}
async handleAction(action: ActionItem<SmartFilter>, filter: SmartFilter) {
switch (action.action) {
case(Action.Delete):
await this.deleteFilter(filter);
break;
default:
break;
}
}
}

View File

@ -3,8 +3,11 @@
@if (alwaysShow || items && items.length > 0) {
<div class="carousel-container mb-3">
<div>
@if (actionables.length > 0) {
<app-card-actionables [actions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
}
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
<a href="javascript:void(0)" class="section-title" >{{title}}</a>
<a [href]="titleLink !== '' ? titleLink : 'javascript:void(0)'" class="section-title">{{title}}</a>
@if (iconClasses !== '') {
<i class="{{iconClasses}} title-icon ms-1" aria-hidden="true"></i>
}

View File

@ -9,17 +9,19 @@ import {
Output,
TemplateRef
} from '@angular/core';
import { Swiper, SwiperEvents } from 'swiper/types';
import { SwiperModule } from 'swiper/angular';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import {Swiper, SwiperEvents} from 'swiper/types';
import {SwiperModule} from 'swiper/angular';
import {NgClass, NgTemplateOutlet} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {ActionItem} from "../../../_services/action-factory.service";
@Component({
selector: 'app-carousel-reel',
templateUrl: './carousel-reel.component.html',
styleUrls: ['./carousel-reel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgClass, SwiperModule, NgTemplateOutlet, TranslocoDirective]
imports: [NgClass, SwiperModule, NgTemplateOutlet, TranslocoDirective, CardActionablesComponent]
})
export class CarouselReelComponent {
@ -29,6 +31,10 @@ export class CarouselReelComponent {
@ContentChild('promptToAdd') promptToAddTemplate!: TemplateRef<any>;
@Input() items: any[] = [];
@Input() title = '';
/**
* If provided, will render the title as an anchor
*/
@Input() titleLink = '';
@Input() clickableTitle: boolean = true;
@Input() iconClasses = '';
/**
@ -39,7 +45,12 @@ export class CarouselReelComponent {
* Track by identity. By default, this has an implementation based on title, item's name, pagesRead, and index
*/
@Input() trackByIdentity: (index: number, item: any) => string = (index: number, item: any) => `${this.title}_${item.id}_${item?.name}_${item?.pagesRead}_${index}`;
/**
* Actionables to render to the left of the title
*/
@Input() actionables: Array<ActionItem<any>> = [];
@Output() sectionClick = new EventEmitter<string>();
@Output() handleAction = new EventEmitter<ActionItem<any>>();
swiper: Swiper | undefined;
@ -67,4 +78,8 @@ export class CarouselReelComponent {
[this.swiper] = eventParams;
this.cdRef.detectChanges();
}
performAction(action: ActionItem<any>) {
this.handleAction.emit(action);
}
}

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read:'manage-smart-filters'">
<form [formGroup]="listForm">
@if (filters.length >= 3) {
@if (filters.length >= 5) {
<div class="mb-3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
@ -12,27 +12,24 @@
</form>
<ul>
@for(f of filters | filter: filterList; track f.name) {
<li class="list-group-item">
<span>
@if (isErrored(f)) {
<i class="fa-solid fa-triangle-exclamation red me-2" [ngbTooltip]="t('errored')"></i>
<span class="visually-hidden">{{t('errored')}}</span>
}
<a [href]="baseUrl + 'all-series?' + f.filter" [target]="target">{{f.name}}</a>
</span>
<div class="float-end">
<button class="btn btn-actions me-2" (click)="editFilter(f)">
<i class="fa-solid fa-pencil" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit')}}</span>
</button>
@for(stream of filters | filter: filterList; track stream.name) {
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete')}}</span>
</button>
</div>
</li>
@if (isErrored(stream)) {
<li class="list-group-item d-block mb-3">
<i class="fa-solid fa-triangle-exclamation red me-2">
<span class="visually-hidden">{{t('errored')}}</span>
</i>
{{stream.name}} - {{t('errored')}}
</li>
} @else {
@if(filterApiMap[stream.name] | async; as data) {
<app-carousel-reel [items]="data" [title]="stream.name" [titleLink]="baseUrl + 'all-series?' + stream.filter" [actionables]="actions" (handleAction)="handleAction($event, stream)">
<ng-template #carouselItem let-item>
<app-series-card [series]="item" [libraryId]="item.libraryId"></app-series-card>
</ng-template>
</app-carousel-reel>
}
}
} @empty {
<li class="list-group-item">
{{t('no-data')}}

View File

@ -5,24 +5,34 @@ import {TranslocoDirective} from "@jsverse/transloco";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ActionService} from "../../../_services/action.service";
import {NgbModal, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {RouterLink} from "@angular/router";
import {APP_BASE_HREF} from "@angular/common";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {APP_BASE_HREF, AsyncPipe} from "@angular/common";
import {EditSmartFilterModalComponent} from "../edit-smart-filter-modal/edit-smart-filter-modal.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
import {Observable, switchMap} from "rxjs";
import {SeriesService} from "../../../_services/series.service";
import {QueryContext} from "../../../_models/metadata/v2/query-context";
import {map, shareReplay} from "rxjs/operators";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {Action, ActionFactoryService, ActionItem} from "../../../_services/action-factory.service";
@Component({
selector: 'app-manage-smart-filters',
imports: [ReactiveFormsModule, TranslocoDirective, FilterPipe, NgbTooltip],
templateUrl: './manage-smart-filters.component.html',
styleUrls: ['./manage-smart-filters.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-manage-smart-filters',
imports: [ReactiveFormsModule, TranslocoDirective, FilterPipe, CarouselReelComponent, SeriesCardComponent, AsyncPipe],
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 filterUtilityService = inject(FilterUtilitiesService);
private readonly seriesService = inject(SeriesService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionService = inject(ActionService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly destroyRef = inject(DestroyRef);
private readonly modelService = inject(NgbModal);
protected readonly baseUrl = inject(APP_BASE_HREF);
@ -33,6 +43,8 @@ export class ManageSmartFiltersComponent {
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterApiMap: { [key: string]: Observable<any> } = {};
actions: Array<ActionItem<SmartFilter>> = this.actionFactoryService.getSmartFilterActions(this.handleAction.bind(this));
filterList = (listItem: SmartFilter) => {
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
@ -46,6 +58,16 @@ export class ManageSmartFiltersComponent {
loadData() {
this.filterService.getAllFilters().subscribe(filters => {
this.filters = filters;
this.filterApiMap = {};
for(let filter of filters) {
this.filterApiMap[filter.name] = this.filterUtilityService.decodeFilter(filter.filter).pipe(
switchMap(filter => {
return this.seriesService.getAllSeriesV2(0, 20, filter, QueryContext.Dashboard);
}))
.pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
}
this.cdRef.markForCheck();
});
}
@ -59,6 +81,17 @@ export class ManageSmartFiltersComponent {
return !decodeURIComponent(filter.filter).includes('¦');
}
handleAction(action: ActionItem<SmartFilter>, smartFilter: SmartFilter) {
switch (action.action) {
case Action.Edit:
this.editFilter(smartFilter);
break;
case Action.Delete:
this.deleteFilter(smartFilter);
break;
}
}
async deleteFilter(f: SmartFilter) {
await this.actionService.deleteFilter(f.id, success => {
if (!success) return;

View File

@ -2757,7 +2757,9 @@
"copy-settings": "Copy Settings From",
"match": "Match",
"match-tooltip": "Match Series with Kavita+ manually",
"reorder": "Reorder"
"reorder": "Reorder",
"rename": "Rename",
"rename-tooltip": "Rename the Smart Filter"
},
"preferences": {