In-Depth Filtering (#850)

* Laying the foundation for the filter rework

* Filtering by Genre is now possible.

* Cleaned up code and preparing for People filtering

* People filtering is hooked up for the frontend

* Filtering now works. On Deck does not work with filtering currently due to a unique implementation.

* More cleanup

* Implemented the ability to reset the filters

* Added a mobile drawer for filtering

* Added some additional cases for NaturalSortComparer. Filter now uses a drawer on smaller screens.

* Fixed a bug where backup service was not pointing to the correct directory.

* Undid the fix, it's working as expected
This commit is contained in:
Joseph Milazzo 2021-12-15 10:23:10 -06:00 committed by GitHub
parent ca893930d3
commit 28688ada8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2354 additions and 187 deletions

View file

@ -13,6 +13,7 @@ export enum PersonRole {
}
export interface Person {
id: number;
name: string;
role: PersonRole;
}

View file

@ -1,38 +1,53 @@
import { MangaFormat } from "./manga-format";
export interface FilterItem {
export interface FilterItem<T> {
title: string;
value: any;
value: T;
selected: boolean;
}
export interface SeriesFilter {
formats: Array<MangaFormat>;
libraries: Array<number>,
readStatus: ReadStatus;
genres: Array<number>;
writers: Array<number>;
penciller: Array<number>;
inker: Array<number>;
colorist: Array<number>;
letterer: Array<number>;
coverArtist: Array<number>;
editor: Array<number>;
publisher: Array<number>;
character: Array<number>;
collectionTags: Array<number>;
rating: number;
}
export interface ReadStatus {
notRead: boolean,
inProgress: boolean,
read: boolean,
}
export const mangaFormatFilters = [
{
title: 'Format: All',
value: null,
selected: false
},
{
title: 'Format: Images',
title: 'Images',
value: MangaFormat.IMAGE,
selected: false
},
{
title: 'Format: EPUB',
title: 'EPUB',
value: MangaFormat.EPUB,
selected: false
},
{
title: 'Format: PDF',
title: 'PDF',
value: MangaFormat.PDF,
selected: false
},
{
title: 'Format: ARCHIVE',
title: 'ARCHIVE',
value: MangaFormat.ARCHIVE,
selected: false
}

View file

@ -4,7 +4,9 @@ import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { ChapterMetadata } from '../_models/chapter-metadata';
import { Genre } from '../_models/genre';
import { AgeRating } from '../_models/metadata/age-rating';
import { Person } from '../_models/person';
@Injectable({
providedIn: 'root'
@ -34,4 +36,12 @@ export class MetadataService {
return this.ageRatingTypes[ageRating];
}));
}
getAllGenres() {
return this.httpClient.get<Genre[]>(this.baseUrl + 'metadata/genres');
}
getAllPeople() {
return this.httpClient.get<Person[]>(this.baseUrl + 'metadata/people');
}
}

View file

@ -8,7 +8,7 @@ import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter';
import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { SeriesFilter } from '../_models/series-filter';
import { ReadStatus, SeriesFilter } from '../_models/series-filter';
import { SeriesMetadata } from '../_models/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
@ -177,15 +177,29 @@ export class SeriesService {
createSeriesFilter(filter?: SeriesFilter) {
const data: SeriesFilter = {
formats: []
formats: [],
libraries: [],
genres: [],
writers: [],
penciller: [],
inker: [],
colorist: [],
letterer: [],
coverArtist: [],
editor: [],
publisher: [],
character: [],
collectionTags: [],
rating: 0,
readStatus: {
read: true,
inProgress: true,
notRead: true
}
};
if (filter) {
if (filter.formats != null) {
data.formats = filter.formats;
}
}
if (filter === undefined) return data;
return data;
return filter;
}
}

View file

@ -6,7 +6,7 @@ import { LibraryService } from './_services/library.service';
import { MessageHubService } from './_services/message-hub.service';
import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-root',
@ -17,7 +17,11 @@ export class AppComponent implements OnInit {
constructor(private accountService: AccountService, public navService: NavService,
private messageHub: MessageHubService, private libraryService: LibraryService,
private router: Router, private ngbModal: NgbModal) {
private router: Router, private ngbModal: NgbModal, private ratingConfig: NgbRatingConfig) {
// Setup default rating config
ratingConfig.max = 5;
ratingConfig.resettable = true;
// Close any open modals when a route change occurs
router.events

View file

@ -10,24 +10,264 @@
</h2>
</div>
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
<i class="fa fa-filter" aria-hidden="true"></i>
<span class="sr-only">Sort / Filter</span>
</button>
</div>
<div class="row no-gutters filter-section" #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
<div class="col">
<form class="ml-2" [formGroup]="filterForm">
<div class="form-group" *ngIf="filters.length > 0">
<label for="series-filter">Filter</label>
<select class="form-control" id="series-filter" formControlName="filter" (ngModelChange)="handleFilterChange($event)" style="max-width: 200px;">
<option [value]="i" *ngFor="let opt of filters; let i = index">{{opt.title}}</option>
</select>
</div>
</form>
<div class="phone-hidden">
<div #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</div>
<div class="not-phone-hidden">
<app-drawer #commentDrawer="drawer" [isOpen]="!filteringCollapsed" [style.--drawer-width]="'300px'" [style.--drawer-background-color]="'#010409'" (drawerClosed)="filteringCollapsed = !filteringCollapsed">
<div header>
<h2 style="margin-top: 0.5rem">Book Settings
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
<span aria-hidden="true">&times;</span>
</button>
</h2>
</div>
<div body class="drawer-body">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</app-drawer>
</div>
<ng-template #filterSection>
<div class="filter-section">
<div class="row no-gutters">
<div class="col-md-3" *ngIf="!filterSettings.formatDisabled">
<div class="form-group">
<label for="format">Format</label>
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3"*ngIf="!filterSettings.libraryDisabled">
<div class="form-group">
<label for="libraries">Libraries</label>
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="!filterSettings.collectionDisabled">
<div class="form-group">
<label for="collections">Collections</label>
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="!filterSettings.genresDisabled">
<div class="form-group">
<label for="genres">Genres</label>
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row no-gutters">
<!-- The People row -->
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
<div class="form-group">
<label for="cover-artist">(Cover) Artists</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
<div class="form-group">
<label for="writers">Writers</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
<div class="form-group">
<label for="publisher">Publisher</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
<div class="form-group">
<label for="Penciller">Penciller</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
<div class="form-group">
<label for="letterer">Letterer</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
<div class="form-group">
<label for="inker">Inker</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
<div class="form-group">
<label for="editor">Editor</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
<div class="form-group">
<label for="colorist">Colorist</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
<div class="form-group">
<label for="character">Character</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row no-gutters">
<!-- Rating/Review/Progress -->
<div class="col" *ngIf="!filterSettings.readProgressDisabled">
<!-- Not sure how to do this on the backend, might have to be a UI control -->
<label>Read Progress</label>
<form [formGroup]="readProgressGroup" class="ml-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="notread" formControlName="notRead">
<label class="form-check-label" for="notread">Unread</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress">
<label class="form-check-label" for="inprogress">In Progress</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="read" formControlName="read">
<label class="form-check-label" for="read">Read</label>
</div>
</form>
</div>
<div class="col" *ngIf="!filterSettings.ratingDisabled">
<label for="ratings">Rating</label>
<form class="form-inline ml-2">
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
<ng-template let-fill="fill" let-index="index">
<span class="star" [class.filled]="(index >= (filter.rating - 1)) && filter.rating > 0" [ngbTooltip]="(index + 1) + ' and up'">&#9733;</span>
</ng-template>
</ngb-rating>
</form>
</div>
<div class="col">
<button class="btn btn-secondary mr-2" (click)="clear()">Clear</button>
<button class="btn btn-primary" (click)="apply()">Apply</button>
</div>
</div>
<div class="row no-gutters">
<!-- Sort by functionalities -->
</div>
</div>
</ng-template>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>

View file

@ -0,0 +1,9 @@
@use '../../../theme/colors';
.star {
font-size: 1.5rem;
color: colors.$rating-empty;
}
.filled {
color: colors.$rating-filled;
}

View file

@ -1,39 +1,42 @@
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { of, ReplaySubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Genre } from 'src/app/_models/genre';
import { Library } from 'src/app/_models/library';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Pagination } from 'src/app/_models/pagination';
import { FilterItem } from 'src/app/_models/series-filter';
import { Person, PersonRole } from 'src/app/_models/person';
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { SeriesService } from 'src/app/_services/series.service';
const FILTER_PAG_REGEX = /[^0-9]/g;
export enum FilterAction {
/**
* If an option is selected on a multi select component
*/
Added = 0,
/**
* If an option is unselected on a multi select component
*/
Removed = 1,
/**
* If an option is selected on a single select component
*/
Selected = 2
}
export interface UpdateFilterEvent {
filterItem: FilterItem;
action: FilterAction;
}
const ANIMATION_SPEED = 300;
export class FilterSettings {
libraryDisabled = false;
formatDisabled = false;
collectionDisabled = false;
genresDisabled = false;
peopleDisabled = false;
readProgressDisabled = false;
ratingDisabled = false;
}
@Component({
selector: 'app-card-detail-layout',
templateUrl: './card-detail-layout.component.html',
styleUrls: ['./card-detail-layout.component.scss']
})
export class CardDetailLayoutComponent implements OnInit {
export class CardDetailLayoutComponent implements OnInit, OnDestroy {
@Input() header: string = '';
@Input() isLoading: boolean = false;
@ -43,32 +46,253 @@ export class CardDetailLayoutComponent implements OnInit {
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = [];
/**
* A list of Filters which can filter the data of the page. If nothing is passed, the control will not show.
*/
@Input() filters: Array<FilterItem> = [];
@Input() trackByIdentity!: (index: number, item: any) => string;
@Input() filterSettings!: FilterSettings;
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
@Output() applyFilter: EventEmitter<SeriesFilter> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
filterForm: FormGroup = new FormGroup({
filter: new FormControl(0, []),
});
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
librarySettings: TypeaheadSettings<FilterItem<Library>> = new TypeaheadSettings();
genreSettings: TypeaheadSettings<FilterItem<Genre>> = new TypeaheadSettings();
collectionSettings: TypeaheadSettings<FilterItem<CollectionTag>> = new TypeaheadSettings();
peopleSettings: {[PersonRole: string]: TypeaheadSettings<FilterItem<Person>>} = {};
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
/**
* Controls the visiblity of extended controls that sit below the main header.
*/
filteringCollapsed: boolean = true;
constructor() { }
filter!: SeriesFilter;
libraries: Array<FilterItem<Library>> = [];
genres: Array<FilterItem<Genre>> = [];
persons: Array<FilterItem<Person>> = [];
collectionTags: Array<FilterItem<CollectionTag>> = [];
readProgressGroup!: FormGroup;
updateApplied: number = 0;
private onDestory: Subject<void> = new Subject();
get PersonRole(): typeof PersonRole {
return PersonRole;
}
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
this.filter = this.seriesService.createSeriesFilter();
this.readProgressGroup = new FormGroup({
read: new FormControl(this.filter.readStatus.read, []),
notRead: new FormControl(this.filter.readStatus.notRead, []),
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
});
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
});
}
ngOnInit(): void {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.filterForm.get('filter')?.value}_${item.id}_${index}`;
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}`;
this.setupFormatTypeahead();
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
}
this.metadataService.getAllGenres().subscribe(genres => {
this.genres = genres.map(genre => {
return {
title: genre.title,
value: genre,
selected: false,
}
});
this.setupGenreTypeahead();
});
this.libraryService.getLibrariesForMember().subscribe(libs => {
this.libraries = libs.map(lib => {
return {
title: lib.name,
value: lib,
selected: true,
}
});
this.setupLibraryTypeahead();
});
this.metadataService.getAllPeople().subscribe(res => {
this.persons = res.map(lib => {
return {
title: lib.name,
value: lib,
selected: true,
}
});
this.setupPersonTypeahead();
});
this.collectionTagService.allTags().subscribe(tags => {
this.collectionTags = tags.map(lib => {
return {
title: lib.title,
value: lib,
selected: false,
}
});
this.setupCollectionTagTypeahead();
});
}
ngOnDestroy() {
this.onDestory.next();
this.onDestory.complete();
}
setupFormatTypeahead() {
this.formatSettings.minCharacters = 0;
this.formatSettings.multiple = true;
this.formatSettings.id = 'format';
this.formatSettings.unique = true;
this.formatSettings.addIfNonExisting = false;
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters);
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
this.formatSettings.savedData = mangaFormatFilters;
}
setupLibraryTypeahead() {
this.librarySettings.minCharacters = 0;
this.librarySettings.multiple = true;
this.librarySettings.id = 'libraries';
this.librarySettings.unique = true;
this.librarySettings.addIfNonExisting = false;
this.librarySettings.fetchFn = (filter: string) => {
return of (this.libraries)
};
this.librarySettings.compareFn = (options: FilterItem<Library>[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
}
setupGenreTypeahead() {
this.genreSettings.minCharacters = 0;
this.genreSettings.multiple = true;
this.genreSettings.id = 'genres';
this.genreSettings.unique = true;
this.genreSettings.addIfNonExisting = false;
this.genreSettings.fetchFn = (filter: string) => {
return of (this.genres)
};
this.genreSettings.compareFn = (options: FilterItem<Genre>[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
}
setupCollectionTagTypeahead() {
this.collectionSettings.minCharacters = 0;
this.collectionSettings.multiple = true;
this.collectionSettings.id = 'collections';
this.collectionSettings.unique = true;
this.collectionSettings.addIfNonExisting = false;
this.collectionSettings.fetchFn = (filter: string) => {
return of (this.collectionTags)
};
this.collectionSettings.compareFn = (options: FilterItem<CollectionTag>[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
}
setupPersonTypeahead() {
this.peopleSettings = {};
var personSettings = this.createBlankPersonSettings('writers');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Writer && this.utilityService.filter(p.value.name, filter)));
};
this.peopleSettings[PersonRole.Writer] = personSettings;
personSettings = this.createBlankPersonSettings('character');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Character && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Character] = personSettings;
personSettings = this.createBlankPersonSettings('colorist');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Colorist && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Colorist] = personSettings;
personSettings = this.createBlankPersonSettings('cover-artist');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.CoverArtist && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.CoverArtist] = personSettings;
personSettings = this.createBlankPersonSettings('editor');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Editor && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Editor] = personSettings;
personSettings = this.createBlankPersonSettings('inker');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Inker && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Inker] = personSettings;
personSettings = this.createBlankPersonSettings('letterer');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Letterer && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Letterer] = personSettings;
personSettings = this.createBlankPersonSettings('penciller');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Penciller && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Penciller] = personSettings;
personSettings = this.createBlankPersonSettings('publisher');
personSettings.fetchFn = (filter: string) => {
return of (this.persons.filter(p => p.value.role == PersonRole.Publisher && this.utilityService.filter(p.title, filter)))
};
this.peopleSettings[PersonRole.Publisher] = personSettings;
}
createBlankPersonSettings(id: string) {
var personSettings = new TypeaheadSettings<FilterItem<Person>>();
personSettings.minCharacters = 0;
personSettings.multiple = true;
personSettings.unique = true;
personSettings.addIfNonExisting = false;
personSettings.id = id;
personSettings.compareFn = (options: FilterItem<Person>[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
return personSettings;
}
onPageChange(page: number) {
this.pageChange.emit(this.pagination);
}
@ -88,11 +312,88 @@ export class CardDetailLayoutComponent implements OnInit {
}
}
handleFilterChange(index: string) {
this.applyFilter.emit({
filterItem: this.filters[parseInt(index, 10)],
action: FilterAction.Selected
});
updateFormatFilters(formats: FilterItem<MangaFormat>[]) {
this.filter.formats = formats.map(item => item.value) || [];
}
updateLibraryFilters(libraries: FilterItem<Library>[]) {
this.filter.libraries = libraries.map(item => item.value.id) || [];
}
updateGenreFilters(genres: FilterItem<Genre>[]) {
this.filter.genres = genres.map(item => item.value.id) || [];
}
updatePersonFilters(persons: FilterItem<Person>[], role: PersonRole) {
switch (role) {
case PersonRole.CoverArtist:
this.filter.coverArtist = persons.map(p => p.value.id);
break;
case PersonRole.Character:
this.filter.character = persons.map(p => p.value.id);
break;
case PersonRole.Colorist:
this.filter.colorist = persons.map(p => p.value.id);
break;
// case PersonRole.Artist:
// this.filter.artist = persons.map(p => p.value.id);
// break;
case PersonRole.Editor:
this.filter.editor = persons.map(p => p.value.id);
break;
case PersonRole.Inker:
this.filter.inker = persons.map(p => p.value.id);
break;
case PersonRole.Letterer:
this.filter.letterer = persons.map(p => p.value.id);
break;
case PersonRole.Penciller:
this.filter.penciller = persons.map(p => p.value.id);
break;
case PersonRole.Publisher:
this.filter.publisher = persons.map(p => p.value.id);
break;
case PersonRole.Writer:
this.filter.writers = persons.map(p => p.value.id);
break;
}
}
updateCollectionFilters(tags: FilterItem<CollectionTag>[]) {
this.filter.collectionTags = tags.map(item => item.value.id) || [];
}
updateRating(rating: any) {
this.filter.rating = rating;
}
updateReadStatus(status: string) {
console.log('readstatus: ', this.filter.readStatus);
if (status === 'read') {
this.filter.readStatus.read = !this.filter.readStatus.read;
} else if (status === 'inProgress') {
this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress;
} else if (status === 'notRead') {
this.filter.readStatus.notRead = !this.filter.readStatus.notRead;
}
}
getPersonsSettings(role: PersonRole) {
return this.peopleSettings[role];
}
clear() {
this.filter = this.seriesService.createSeriesFilter();
this.resetTypeaheads.next(true);
this.applyFilter.emit(this.filter);
this.updateApplied++;
}
apply() {
this.applyFilter.emit(this.filter);
this.updateApplied++;
}
}

View file

@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxFileDropModule } from 'ngx-file-drop';
@ -56,6 +56,7 @@ import { FileInfoComponent } from './file-info/file-info.component';
NgbNavModule,
NgbTooltipModule, // Card item
NgbCollapseModule,
NgbRatingModule,
NgbNavModule, //Series Detail
LazyLoadImageModule,

View file

@ -49,4 +49,5 @@ export class ChapterMetadataDetailComponent implements OnInit {
// }
}
}

View file

@ -33,7 +33,6 @@
[items]="series"
[pagination]="seriesPagination"
(pageChange)="onPageChange($event)"
[filters]="filters"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">

View file

@ -6,15 +6,13 @@ import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
import { SeriesFilter } from 'src/app/_models/series-filter';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
@ -39,10 +37,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
seriesPagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = [];
isAdmin: boolean = false;
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
formats: []
};
filter: SeriesFilter | undefined = undefined;
private onDestory: Subject<void> = new Subject<void>();
@ -174,8 +169,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
});
}
updateFilter(data: UpdateFilterEvent) {
this.filter.formats = [data.filterItem.value];
updateFilter(data: SeriesFilter) {
this.filter = data;
if (this.seriesPagination !== undefined && this.seriesPagination !== null) {
this.seriesPagination.currentPage = 1;
this.onPageChange(this.seriesPagination);

View file

@ -4,7 +4,6 @@
[items]="series"
[actions]="actions"
[pagination]="pagination"
[filters]="filters"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
>

View file

@ -4,13 +4,12 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { KEY_CODES } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { Library } from '../_models/library';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
import { SeriesFilter } from '../_models/series-filter';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service';
import { LibraryService } from '../_services/library.service';
@ -30,10 +29,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
loadingSeries = false;
pagination!: Pagination;
actions: ActionItem<Library>[] = [];
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
formats: []
};
filter: SeriesFilter | undefined = undefined;
onDestroy: Subject<void> = new Subject<void>();
bulkActionCallback = (action: Action, data: any) => {
@ -134,8 +130,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
}
}
updateFilter(data: UpdateFilterEvent) {
this.filter.formats = [data.filterItem.value];
updateFilter(data: SeriesFilter) {
this.filter = data;
if (this.pagination !== undefined && this.pagination !== null) {
this.pagination.currentPage = 1;
this.onPageChange(this.pagination);

View file

@ -2,11 +2,10 @@
<app-card-detail-layout header="On Deck"
[isLoading]="isLoading"
[items]="series"
[filters]="filters"
[pagination]="pagination"
[filterSettings]="filterSettings"
(pageChange)="onPageChange($event)"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>

View file

@ -3,11 +3,11 @@ import { Title } from '@angular/platform-browser';
import { Router, ActivatedRoute } from '@angular/router';
import { take } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
import { KEY_CODES } from '../shared/_services/utility.service';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, SeriesFilter, mangaFormatFilters } from '../_models/series-filter';
import { SeriesFilter} from '../_models/series-filter';
import { Action } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service';
import { SeriesService } from '../_services/series.service';
@ -23,10 +23,8 @@ export class OnDeckComponent implements OnInit {
series: Series[] = [];
pagination!: Pagination;
libraryId!: number;
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
formats: []
};
filter: SeriesFilter | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
@ -35,6 +33,7 @@ export class OnDeckComponent implements OnInit {
if (this.pagination === undefined || this.pagination === null) {
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
}
this.filterSettings.readProgressDisabled = true;
this.loadPage();
}
@ -63,8 +62,8 @@ export class OnDeckComponent implements OnInit {
this.loadPage();
}
updateFilter(data: UpdateFilterEvent) {
this.filter.formats = [data.filterItem.value];
updateFilter(data: SeriesFilter) {
this.filter = data;
if (this.pagination !== undefined && this.pagination !== null) {
this.pagination.currentPage = 1;
this.onPageChange(this.pagination);

View file

@ -3,8 +3,7 @@
[isLoading]="isLoading"
[items]="series"
[pagination]="pagination"
[filters]="filters"
(applyFilter)="updateFilter($event)"
(applyFilter)="applyFilter($event)"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">

View file

@ -4,12 +4,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { KEY_CODES } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
import { SeriesFilter } from '../_models/series-filter';
import { Action } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service';
import { MessageHubService } from '../_services/message-hub.service';
@ -30,10 +29,7 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
pagination!: Pagination;
libraryId!: number;
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
formats: []
};
filter: SeriesFilter | undefined = undefined;
onDestroy: Subject<void> = new Subject();
@ -81,9 +77,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
this.loadPage();
}
updateFilter(data: UpdateFilterEvent) {
// TODO: Move this into card-layout component. It's the same except for callback
this.filter.formats = [data.filterItem.value];
applyFilter(data: SeriesFilter) {
this.filter = data;
if (this.pagination !== undefined && this.pagination !== null) {
this.pagination.currentPage = 1;
this.onPageChange(this.pagination);

View file

@ -49,22 +49,11 @@
</div>
</div>
<div class="row no-gutters">
<!-- TODO: This will be the first of reviews section. Reviews will show your plus other peoples reviews in media cards like Plex does and this will be below metadata -->
<app-read-more class="user-review {{userReview ? 'mt-1' : ''}}" [text]="series?.userReview || ''" [maxLength]="250"></app-read-more>
</div>
<div class="row no-gutters {{series?.userReview ? '' : 'mt-2'}}">
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
</div>
<div *ngIf="seriesMetadata" class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [series]="series"></app-series-metadata-detail>
<!-- <div class="row no-gutters mt-1" *ngIf="series.format != MangaFormat.UNKNOWN">
<div class="col-md-4">
<h5>Type</h5>
</div>
<div class="col-md-8">
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed"><app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format></app-tag-badge>
</div>
</div> -->
</div>

View file

@ -63,7 +63,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
activeTabId = 2;
hasNonSpecialVolumeChapters = true;
seriesSummary: string = '';
userReview: string = '';
libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null;
@ -148,7 +147,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
constructor(private route: ActivatedRoute, private seriesService: SeriesService,
private ratingConfig: NgbRatingConfig, private router: Router,
private router: Router, public bulkSelectionService: BulkSelectionService,
private modalService: NgbModal, public readerService: ReaderService,
public utilityService: UtilityService, private toastr: ToastrService,
private accountService: AccountService, public imageService: ImageService,
@ -156,8 +155,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService,
public imageSerivce: ImageService, private messageHub: MessageHubService,
public bulkSelectionService: BulkSelectionService) {
ratingConfig.max = 5;
) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
@ -392,10 +390,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
createHTML() {
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}
this.userReview = (this.series.userReview === null ? '' : this.series.userReview).replace(/\n/g, '<br>');
}

View file

@ -1,3 +1,7 @@
<div class="row no-gutters {{series?.userReview ? '' : 'mt-2'}}">
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
</div>
<!-- This first row will have random information about the series-->
<div class="row no-gutters" *ngIf="seriesMetadata.ageRating">
<app-tag-badge title="Age Rating">{{ageRatingName}}</app-tag-badge>

View file

@ -23,6 +23,10 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
* String representation of AgeRating enum
*/
ageRatingName: string = '';
/**
* Html representation of Series Summary
*/
seriesSummary: string = '';
get MangaFormat(): typeof MangaFormat {
return MangaFormat;
@ -46,6 +50,11 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
this.metadataService.getAgeRating(this.seriesMetadata.ageRating).subscribe(rating => {
this.ageRatingName = rating;
});
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}
}
ngOnInit(): void {

View file

@ -88,6 +88,12 @@ export class UtilityService {
return cleaned;
}
filter(input: string, filter: string): boolean {
if (input === null || filter === null) return false;
const reg = /[_\.\-]/gi;
return input.toUpperCase().replace(reg, '').includes(filter.toUpperCase().replace(reg, ''));
}
mangaFormat(format: MangaFormat): string {
switch (format) {
case MangaFormat.EPUB:

View file

@ -1,7 +1,5 @@
<form [formGroup]="typeaheadForm">
<ng-container *ngIf="settings.multiple" >
<div class="typeahead-input" (click)="onInputFocus($event)">
<div>
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index">
@ -13,6 +11,7 @@
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoadingOptions">
<span class="sr-only">Loading...</span>
</div>
<!-- TODO: Add a clear all button -->
</div>
</div>
<div class="dropdown" *ngIf="hasFocus">

View file

@ -1,6 +1,6 @@
import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Observable, of, Subject } from 'rxjs';
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
import { debounceTime, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { KEY_CODES } from '../shared/_services/utility.service';
import { TypeaheadSettings } from './typeahead-settings';
@ -143,6 +143,10 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
@Input() settings!: TypeaheadSettings<any>;
/**
* When true, component will re-init and set back to false.
*/
@Input() reset: Subject<boolean> = new ReplaySubject(1);
@Output() selectedData = new EventEmitter<any[] | any>();
@Output() newItemAdded = new EventEmitter<any[] | any>();
@ -167,6 +171,14 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
ngOnInit() {
this.reset.pipe(takeUntil(this.onDestroy)).subscribe((reset: boolean) => {
this.init();
});
this.init();
}
init() {
if (this.settings.compareFn === undefined && this.settings.multiple) {
console.error('A compare function must be defined');
return;

View file

@ -14,6 +14,20 @@ $dark-form-border: rgba(239, 239, 239, 0.125);
$dark-form-readonly: #434648;
$dark-item-accent-bg: #292d32;
//=========================
// Ratings
//=========================
$rating-filled: $primary-color;
$rating-empty: #b0c4de;
//=========================
// Drawers
//=========================
// :root {
// --drawer-background-color: #FFF;
// }
$theme-colors: (
"primary": $primary-color,
"danger": $error-color