mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Smart Filter UX (#3768)
This commit is contained in:
parent
06a6d9e03b
commit
9ee5821cb2
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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')}}
|
||||
|
@ -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;
|
||||
|
@ -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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user