Collection Rework (#2830)
This commit is contained in:
parent
0dacc061f1
commit
deaaccb96a
93 changed files with 5413 additions and 1120 deletions
|
@ -1,11 +1,33 @@
|
|||
export interface CollectionTag {
|
||||
id: number;
|
||||
title: string;
|
||||
promoted: boolean;
|
||||
/**
|
||||
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
|
||||
*/
|
||||
coverImage: string;
|
||||
coverImageLocked: boolean;
|
||||
summary: string;
|
||||
}
|
||||
import {ScrobbleProvider} from "../_services/scrobbling.service";
|
||||
import {AgeRating} from "./metadata/age-rating";
|
||||
|
||||
// Deprecated in v0.8, replaced with UserCollection
|
||||
// export interface CollectionTag {
|
||||
// id: number;
|
||||
// title: string;
|
||||
// promoted: boolean;
|
||||
// /**
|
||||
// * This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
|
||||
// */
|
||||
// coverImage: string;
|
||||
// coverImageLocked: boolean;
|
||||
// summary: string;
|
||||
// }
|
||||
|
||||
export interface UserCollection {
|
||||
id: number;
|
||||
title: string;
|
||||
promoted: boolean;
|
||||
/**
|
||||
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
|
||||
*/
|
||||
coverImage: string;
|
||||
coverImageLocked: boolean;
|
||||
summary: string;
|
||||
lastSyncUtc: string;
|
||||
owner: string;
|
||||
source: ScrobbleProvider;
|
||||
sourceUrl: string | null;
|
||||
ageRating: AgeRating;
|
||||
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { CollectionTag } from "../collection-tag";
|
||||
import { Genre } from "./genre";
|
||||
import { AgeRating } from "./age-rating";
|
||||
import { PublicationStatus } from "./publication-status";
|
||||
|
@ -12,7 +11,6 @@ export interface SeriesMetadata {
|
|||
totalCount: number;
|
||||
maxCount: number;
|
||||
|
||||
collectionTags: Array<CollectionTag>;
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
|
|
|
@ -13,15 +13,15 @@ export class MangaFormatIconPipe implements PipeTransform {
|
|||
transform(format: MangaFormat): string {
|
||||
switch (format) {
|
||||
case MangaFormat.EPUB:
|
||||
return 'fa-book';
|
||||
return 'fa fa-book';
|
||||
case MangaFormat.ARCHIVE:
|
||||
return 'fa-file-archive';
|
||||
return 'fa-solid fa-file-zipper';
|
||||
case MangaFormat.IMAGE:
|
||||
return 'fa-image';
|
||||
return 'fa-solid fa-file-image';
|
||||
case MangaFormat.PDF:
|
||||
return 'fa-file-pdf';
|
||||
return 'fa-solid fa-file-pdf';
|
||||
case MangaFormat.UNKNOWN:
|
||||
return 'fa-question';
|
||||
return 'fa-solid fa-file-circle-question';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,9 @@ export enum Role {
|
|||
Bookmark = 'Bookmark',
|
||||
Download = 'Download',
|
||||
ChangeRestriction = 'Change Restriction',
|
||||
ReadOnly = 'Read Only'
|
||||
ReadOnly = 'Read Only',
|
||||
Login = 'Login',
|
||||
Promote = 'Promote',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -96,6 +98,10 @@ export class AccountService {
|
|||
return user && user.roles.includes(Role.ReadOnly);
|
||||
}
|
||||
|
||||
hasPromoteRole(user: User) {
|
||||
return user && user.roles.includes(Role.Promote) || user.roles.includes(Role.Admin);
|
||||
}
|
||||
|
||||
getRoles() {
|
||||
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { map, Observable, shareReplay } from 'rxjs';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
import { Device } from '../_models/device/device';
|
||||
import { Library } from '../_models/library/library';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
|
@ -10,6 +10,7 @@ import { Volume } from '../_models/volume';
|
|||
import { AccountService } from './account.service';
|
||||
import { DeviceService } from './device.service';
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {User} from "../_models/user";
|
||||
|
||||
export enum Action {
|
||||
Submenu = -1,
|
||||
|
@ -97,12 +98,23 @@ export enum Action {
|
|||
RemoveRuleGroup = 21,
|
||||
MarkAsVisible = 22,
|
||||
MarkAsInvisible = 23,
|
||||
/**
|
||||
* Promotes the underlying item (Reading List, Collection)
|
||||
*/
|
||||
Promote = 24,
|
||||
UnPromote = 25
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for an action
|
||||
*/
|
||||
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
|
||||
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
|
||||
|
||||
export interface ActionItem<T> {
|
||||
title: string;
|
||||
action: Action;
|
||||
callback: (action: ActionItem<T>, data: T) => void;
|
||||
callback: ActionCallback<T>;
|
||||
requiresAdmin: boolean;
|
||||
children: Array<ActionItem<T>>;
|
||||
/**
|
||||
|
@ -132,7 +144,7 @@ export class ActionFactoryService {
|
|||
|
||||
chapterActions: Array<ActionItem<Chapter>> = [];
|
||||
|
||||
collectionTagActions: Array<ActionItem<CollectionTag>> = [];
|
||||
collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||
|
||||
readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
|
||||
|
@ -141,13 +153,11 @@ export class ActionFactoryService {
|
|||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
hasDownloadRole = false;
|
||||
|
||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||
this.accountService.currentUser$.subscribe((user) => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.hasDownloadRole = this.accountService.hasDownloadRole(user);
|
||||
} else {
|
||||
this._resetActions();
|
||||
return; // If user is logged out, we don't need to do anything
|
||||
|
@ -157,39 +167,39 @@ export class ActionFactoryService {
|
|||
});
|
||||
}
|
||||
|
||||
getLibraryActions(callback: (action: ActionItem<Library>, library: Library) => void) {
|
||||
getLibraryActions(callback: ActionCallback<Library>) {
|
||||
return this.applyCallbackToList(this.libraryActions, callback);
|
||||
}
|
||||
|
||||
getSeriesActions(callback: (action: ActionItem<Series>, series: Series) => void) {
|
||||
getSeriesActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.seriesActions, callback);
|
||||
}
|
||||
|
||||
getSideNavStreamActions(callback: (action: ActionItem<SideNavStream>, series: SideNavStream) => void) {
|
||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
|
||||
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
||||
}
|
||||
|
||||
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
|
||||
getVolumeActions(callback: ActionCallback<Volume>) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback);
|
||||
}
|
||||
|
||||
getChapterActions(callback: (action: ActionItem<Chapter>, chapter: Chapter) => void) {
|
||||
getChapterActions(callback: ActionCallback<Chapter>) {
|
||||
return this.applyCallbackToList(this.chapterActions, callback);
|
||||
}
|
||||
|
||||
getCollectionTagActions(callback: (action: ActionItem<CollectionTag>, collectionTag: CollectionTag) => void) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback);
|
||||
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback);
|
||||
}
|
||||
|
||||
getReadingListActions(callback: (action: ActionItem<ReadingList>, readingList: ReadingList) => void) {
|
||||
getReadingListActions(callback: ActionCallback<ReadingList>) {
|
||||
return this.applyCallbackToList(this.readingListActions, callback);
|
||||
}
|
||||
|
||||
getBookmarkActions(callback: (action: ActionItem<Series>, series: Series) => void) {
|
||||
getBookmarkActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.bookmarkActions, callback);
|
||||
}
|
||||
|
||||
getMetadataFilterActions(callback: (action: ActionItem<any>, data: any) => void) {
|
||||
getMetadataFilterActions(callback: ActionCallback<any>) {
|
||||
const actions = [
|
||||
{title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
|
||||
{title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
|
||||
|
@ -260,7 +270,7 @@ export class ActionFactoryService {
|
|||
action: Action.Edit,
|
||||
title: 'edit',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
@ -271,6 +281,20 @@ export class ActionFactoryService {
|
|||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Promote,
|
||||
title: 'promote',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.UnPromote,
|
||||
title: 'unpromote',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.seriesActions = [
|
||||
|
@ -326,7 +350,7 @@ export class ActionFactoryService {
|
|||
action: Action.AddToCollection,
|
||||
title: 'add-to-collection',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -20,6 +20,8 @@ import { MemberService } from './member.service';
|
|||
import { ReaderService } from './reader.service';
|
||||
import { SeriesService } from './series.service';
|
||||
import {translate, TranslocoService} from "@ngneat/transloco";
|
||||
import {UserCollection} from "../_models/collection-tag";
|
||||
import {CollectionTagService} from "./collection-tag.service";
|
||||
|
||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||
export type SeriesActionCallback = (series: Series) => void;
|
||||
|
@ -43,7 +45,8 @@ export class ActionService implements OnDestroy {
|
|||
|
||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService) { }
|
||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
|
||||
private readonly collectionTagService: CollectionTagService) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
|
@ -380,6 +383,43 @@ export class ActionService implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all series as Unread.
|
||||
* @param collections UserCollection, should have id, pagesRead populated
|
||||
* @param promoted boolean, promoted state
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
promoteMultipleCollections(collections: Array<UserCollection>, promoted: boolean, callback?: BooleanActionCallback) {
|
||||
this.collectionTagService.promoteMultipleCollections(collections.map(v => v.id), promoted).pipe(take(1)).subscribe(() => {
|
||||
if (promoted) {
|
||||
this.toastr.success(translate('toasts.collections-promoted'));
|
||||
} else {
|
||||
this.toastr.success(translate('toasts.collections-unpromoted'));
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple collections
|
||||
* @param collections UserCollection, should have id, pagesRead populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
async deleteMultipleCollections(collections: Array<UserCollection>, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-collections'))) return;
|
||||
|
||||
this.collectionTagService.deleteMultipleCollections(collections.map(v => v.id)).pipe(take(1)).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.collections-deleted'));
|
||||
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { ImageService } from './image.service';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {MalStack} from "../_models/collection/mal-stack";
|
||||
import {Action, ActionItem} from "./action-factory.service";
|
||||
import {User} from "../_models/user";
|
||||
import {AccountService} from "./account.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -14,24 +15,25 @@ export class CollectionTagService {
|
|||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
|
||||
constructor(private httpClient: HttpClient, private accountService: AccountService) { }
|
||||
|
||||
allTags() {
|
||||
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/');
|
||||
allCollections(ownedOnly = false) {
|
||||
return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection?ownedOnly=' + ownedOnly);
|
||||
}
|
||||
|
||||
search(query: string) {
|
||||
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => {
|
||||
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
|
||||
return tags;
|
||||
}));
|
||||
allCollectionsForSeries(seriesId: number, ownedOnly = false) {
|
||||
return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection/all-series?ownedOnly=' + ownedOnly + '&seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
updateTag(tag: CollectionTag) {
|
||||
updateTag(tag: UserCollection) {
|
||||
return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse);
|
||||
}
|
||||
|
||||
updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array<number>) {
|
||||
promoteMultipleCollections(tags: Array<number>, promoted: boolean) {
|
||||
return this.httpClient.post(this.baseUrl + 'collection/promote-multiple', {collectionIds: tags, promoted}, TextResonse);
|
||||
}
|
||||
|
||||
updateSeriesForTag(tag: UserCollection, seriesIdsToRemove: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse);
|
||||
}
|
||||
|
||||
|
@ -47,7 +49,19 @@ export class CollectionTagService {
|
|||
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
|
||||
}
|
||||
|
||||
deleteMultipleCollections(tags: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'collection/delete-multiple', {collectionIds: tags}, TextResonse);
|
||||
}
|
||||
|
||||
getMalStacks() {
|
||||
return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks');
|
||||
}
|
||||
|
||||
actionListFilter(action: ActionItem<UserCollection>, user: User) {
|
||||
const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user);
|
||||
const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;
|
||||
|
||||
if (isPromotionAction) return canPromote;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { environment } from 'src/environments/environment';
|
|||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { ChapterMetadata } from '../_models/metadata/chapter-metadata';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { UserCollection } from '../_models/collection-tag';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { RelatedSeries } from '../_models/series-detail/related-series';
|
||||
|
@ -162,16 +162,12 @@ export class SeriesService {
|
|||
}
|
||||
|
||||
getMetadata(seriesId: number) {
|
||||
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
|
||||
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
|
||||
return items;
|
||||
}));
|
||||
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) {
|
||||
updateMetadata(seriesMetadata: SeriesMetadata) {
|
||||
const data = {
|
||||
seriesMetadata,
|
||||
collectionTags,
|
||||
};
|
||||
return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { take } from 'rxjs';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
||||
|
@ -17,6 +26,10 @@ import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
|||
})
|
||||
export class CardActionablesComponent implements OnInit {
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Input() iconClass = 'fa-ellipsis-v';
|
||||
@Input() btnClass = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
|
@ -27,20 +40,22 @@ export class CardActionablesComponent implements OnInit {
|
|||
|
||||
isAdmin: boolean = false;
|
||||
canDownload: boolean = false;
|
||||
canPromote: boolean = false;
|
||||
submenu: {[key: string]: NgbDropdown} = {};
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe((user) => {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
||||
if (!user) return;
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.canDownload = this.accountService.hasDownloadRole(user);
|
||||
this.canPromote = this.accountService.hasPromoteRole(user);
|
||||
|
||||
// We want to avoid an empty menu when user doesn't have access to anything
|
||||
if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) {
|
||||
this.actions = [];
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -61,7 +76,10 @@ export class CardActionablesComponent implements OnInit {
|
|||
willRenderAction(action: ActionItem<any>) {
|
||||
return (action.requiresAdmin && this.isAdmin)
|
||||
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|
||||
|| (!action.requiresAdmin && action.action !== Action.Download);
|
||||
|| (!action.requiresAdmin && action.action !== Action.Download)
|
||||
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|
||||
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
|
||||
;
|
||||
}
|
||||
|
||||
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
|
||||
|
|
|
@ -6,23 +6,37 @@
|
|||
</div>
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3" *ngIf="lists.length >= 5">
|
||||
<label for="filter" class="form-label">{{t('filter-label')}}</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)="listForm.get('filterQuery')?.setValue('');">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item clickable" tabindex="0" role="option" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
|
||||
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" [title]="t('promoted')"></i>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">{{t('no-data')}}</li>
|
||||
<li class="list-group-item" *ngIf="loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
@if (lists.length >= 5) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter-label')}}</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)="listForm.get('filterQuery')?.setValue('');">Clear</button>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="list-group">
|
||||
@for(collectionTag of lists | filter: filterList; let i = $index; track collectionTag.title) {
|
||||
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToCollection(collectionTag)">
|
||||
{{collectionTag.title}}
|
||||
@if (collectionTag.promoted) {
|
||||
<i class="fa fa-angle-double-up" [title]="t('promoted')"></i>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (lists.length === 0 && !loading) {
|
||||
<li class="list-group-item">{{t('no-data')}}</li>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content: normal">
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import {UserCollection} from 'src/app/_models/collection-tag';
|
||||
import { ReadingList } from 'src/app/_models/reading-list';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import {CommonModule} from "@angular/common";
|
||||
|
@ -31,28 +31,25 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco
|
|||
})
|
||||
export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
||||
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@Input({required: true}) title!: string;
|
||||
/**
|
||||
* Series Ids to add to Collection Tag
|
||||
*/
|
||||
@Input() seriesIds: Array<number> = [];
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
/**
|
||||
* All existing collections sorted by recent use date
|
||||
*/
|
||||
lists: Array<CollectionTag> = [];
|
||||
lists: Array<UserCollection> = [];
|
||||
loading: boolean = false;
|
||||
listForm: FormGroup = new FormGroup({});
|
||||
|
||||
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
|
||||
|
||||
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
|
||||
constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService,
|
||||
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.listForm.addControl('title', new FormControl(this.title, []));
|
||||
|
@ -60,7 +57,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
|||
|
||||
this.loading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.collectionService.allTags().subscribe(tags => {
|
||||
this.collectionService.allCollections(true).subscribe(tags => {
|
||||
this.lists = tags;
|
||||
this.loading = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -87,7 +84,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
addToCollection(tag: CollectionTag) {
|
||||
addToCollection(tag: UserCollection) {
|
||||
if (this.seriesIds.length === 0) return;
|
||||
|
||||
this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => {
|
||||
|
|
|
@ -16,14 +16,16 @@
|
|||
<label for="library-name" class="form-label">{{t('name-label')}}</label>
|
||||
<input id="library-name" class="form-control" formControlName="title" type="text"
|
||||
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
@if (collectionTagForm.dirty || collectionTagForm.touched) {
|
||||
<div id="inviteForm-validations" class="invalid-feedback">
|
||||
@if (collectionTagForm.get('title')?.errors?.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
}
|
||||
@if (collectionTagForm.get('title')?.errors?.duplicateName) {
|
||||
<div>{{t('name-validation')}}</div>
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
|
||||
{{t('name-validation')}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 ms-2">
|
||||
<div class="form-check form-switch">
|
||||
|
@ -49,32 +51,46 @@
|
|||
<li [ngbNavItem]="TabID.Series">
|
||||
<a ngbNavLink>{{t(TabID.Series)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<h6>{{t('series-title')}}</h6>
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let item of series; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
@if (!isLoading) {
|
||||
<div class="list-group">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</form>
|
||||
<div class="form-check">
|
||||
<input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
|
||||
</div>
|
||||
<ul>
|
||||
@for (item of series | filter: filterList; let i = $index; track item.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<ngb-pagination
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
|
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {
|
||||
NgbActiveModal,
|
||||
|
@ -15,25 +7,30 @@ import {
|
|||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet,
|
||||
NgbPagination, NgbTooltip
|
||||
NgbPagination,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { debounceTime, distinctUntilChanged, forkJoin, switchMap, tap } from 'rxjs';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
|
||||
import {UserCollection} from 'src/app/_models/collection-tag';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {CollectionTagService} from 'src/app/_services/collection-tag.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {UploadService} from 'src/app/_services/upload.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {CommonModule, NgTemplateOutlet} from "@angular/common";
|
||||
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
|
||||
|
||||
enum TabID {
|
||||
|
@ -45,14 +42,33 @@ enum TabID {
|
|||
@Component({
|
||||
selector: 'app-edit-collection-tags',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective],
|
||||
imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
|
||||
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe],
|
||||
templateUrl: './edit-collection-tags.component.html',
|
||||
styleUrls: ['./edit-collection-tags.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditCollectionTagsComponent implements OnInit {
|
||||
|
||||
@Input({required: true}) tag!: CollectionTag;
|
||||
public readonly modal = inject(NgbActiveModal);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly imageService = inject(ImageService);
|
||||
private readonly uploadService = inject(UploadService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
|
||||
@Input({required: true}) tag!: UserCollection;
|
||||
|
||||
series: Array<Series> = [];
|
||||
selections!: SelectionModel<Series>;
|
||||
isLoading: boolean = true;
|
||||
|
@ -64,25 +80,18 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
active = TabID.General;
|
||||
imageUrls: Array<string> = [];
|
||||
selectedCover: string = '';
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
formGroup = new FormGroup({'filter': new FormControl('', [])});
|
||||
|
||||
|
||||
get hasSomeSelected() {
|
||||
return this.selections != null && this.selections.hasSomeSelected();
|
||||
}
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
filterList = (listItem: Series) => {
|
||||
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
|
||||
return listItem.name.toLowerCase().indexOf(query) >= 0 || listItem.localizedName.toLowerCase().indexOf(query) >= 0;
|
||||
}
|
||||
|
||||
get TabID() {
|
||||
return TabID;
|
||||
}
|
||||
|
||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService,
|
||||
private imageService: ImageService, private uploadService: UploadService,
|
||||
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.pagination == undefined) {
|
||||
|
@ -96,6 +105,20 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
promoted: new FormControl(this.tag.promoted, { nonNullable: true, validators: [] }),
|
||||
});
|
||||
|
||||
if (this.tag.source !== ScrobbleProvider.Kavita) {
|
||||
this.collectionTagForm.get('title')?.disable();
|
||||
this.collectionTagForm.get('summary')?.disable();
|
||||
}
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
if (!user) return;
|
||||
if (!this.accountService.hasPromoteRole(user)) {
|
||||
this.collectionTagForm.get('promoted')?.disable();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.collectionTagForm.get('title')?.valueChanges.pipe(
|
||||
debounceTime(100),
|
||||
distinctUntilChanged(),
|
||||
|
@ -169,6 +192,9 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
const unselectedIds = this.selections.unselected().map(s => s.id);
|
||||
const tag = this.collectionTagForm.value;
|
||||
tag.id = this.tag.id;
|
||||
tag.title = this.collectionTagForm.get('title')!.value;
|
||||
tag.summary = this.collectionTagForm.get('summary')!.value;
|
||||
|
||||
|
||||
if (unselectedIds.length == this.series.length &&
|
||||
!await this.confirmService.confirm(translate('toasts.no-series-collection-warning'))) {
|
||||
|
@ -177,9 +203,13 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
|
||||
const apis = [
|
||||
this.collectionService.updateTag(tag),
|
||||
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
|
||||
];
|
||||
|
||||
const unselectedSeries = this.selections.unselected().map(s => s.id);
|
||||
if (unselectedSeries.length > 0) {
|
||||
apis.push(this.collectionService.updateSeriesForTag(tag, unselectedSeries));
|
||||
}
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover));
|
||||
}
|
||||
|
@ -207,5 +237,4 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -72,13 +72,15 @@
|
|||
<div class="row g-0">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">{{t('collections-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true">
|
||||
<label for="language" class="form-label">{{t('language-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
|
||||
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
|
||||
(newItemAdded)="metadata.languageLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
{{item.title}} ({{item.isoCode}})
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
|
@ -138,22 +140,10 @@
|
|||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-4 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="language" class="form-label">{{t('language-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
|
||||
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
|
||||
(newItemAdded)="metadata.languageLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}} ({{item.isoCode}})
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12 pe-2">
|
||||
<!-- <div class="col-lg-4 col-md-12 pe-2">-->
|
||||
<!-- -->
|
||||
<!-- </div>-->
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</label>
|
||||
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
|
||||
|
@ -164,7 +154,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12">
|
||||
<div class="col-lg-6 col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</label>
|
||||
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
|
||||
|
|
|
@ -22,7 +22,6 @@ import { map } from 'rxjs/operators';
|
|||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings';
|
||||
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Genre } from 'src/app/_models/metadata/genre';
|
||||
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||
import { Language } from 'src/app/_models/metadata/language';
|
||||
|
@ -31,7 +30,6 @@ import { Person, PersonRole } from 'src/app/_models/metadata/person';
|
|||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata';
|
||||
import { Tag } from 'src/app/_models/tag';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
|
@ -119,7 +117,6 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
private readonly fb = inject(FormBuilder);
|
||||
public readonly imageService = inject(ImageService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly uploadService = inject(UploadService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
@ -155,10 +152,8 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
collectionTagSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
|
||||
collectionTags: CollectionTag[] = [];
|
||||
tags: Tag[] = [];
|
||||
genres: Genre[] = [];
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
|
@ -330,44 +325,15 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
|
||||
setupTypeaheads() {
|
||||
forkJoin([
|
||||
this.setupCollectionTagsSettings(),
|
||||
this.setupTagSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
this.setupLanguageTypeahead()
|
||||
]).subscribe(results => {
|
||||
this.collectionTags = this.metadata.collectionTags;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
setupCollectionTagsSettings() {
|
||||
this.collectionTagSettings.minCharacters = 0;
|
||||
this.collectionTagSettings.multiple = true;
|
||||
this.collectionTagSettings.id = 'collections';
|
||||
this.collectionTagSettings.unique = true;
|
||||
this.collectionTagSettings.addIfNonExisting = true;
|
||||
this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter)));
|
||||
this.collectionTagSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
|
||||
});
|
||||
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.title === b.title;
|
||||
}
|
||||
|
||||
if (this.metadata.collectionTags) {
|
||||
this.collectionTagSettings.savedData = this.metadata.collectionTags;
|
||||
}
|
||||
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupTagSettings() {
|
||||
this.tagsSettings.minCharacters = 0;
|
||||
this.tagsSettings.multiple = true;
|
||||
|
@ -545,10 +511,6 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
fetchCollectionTags(filter: string = '') {
|
||||
return this.collectionService.search(filter);
|
||||
}
|
||||
|
||||
updateWeblinks(items: Array<string>) {
|
||||
this.metadata.webLinks = items.map(s => s.replaceAll(',', '%2C')).join(',');
|
||||
}
|
||||
|
@ -559,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
|
||||
|
||||
const apis = [
|
||||
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
|
||||
this.seriesService.updateMetadata(this.metadata)
|
||||
];
|
||||
|
||||
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
|
||||
|
@ -585,10 +547,6 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
updateCollections(tags: CollectionTag[]) {
|
||||
this.collectionTags = tags;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateTags(tags: Tag[]) {
|
||||
this.tags = tags;
|
||||
|
|
|
@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs';
|
|||
import {filter} from 'rxjs/operators';
|
||||
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
|
||||
|
||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream';
|
||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection';
|
||||
|
||||
/**
|
||||
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
|
||||
|
@ -155,6 +155,10 @@ export class BulkSelectionService {
|
|||
return this.applyFilterToList(this.actionFactory.getSideNavStreamActions(callback), [Action.MarkAsInvisible, Action.MarkAsVisible]);
|
||||
}
|
||||
|
||||
if (Object.keys(this.selectedCards).filter(item => item === 'collection').length > 0) {
|
||||
return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
|
||||
}
|
||||
|
||||
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
|
||||
}
|
||||
|
||||
|
|
|
@ -31,45 +31,53 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
|
||||
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
|
||||
<div class="count" *ngIf="count > 1">
|
||||
<span class="badge bg-primary">{{count}}</span>
|
||||
</div>
|
||||
<div class="card-overlay"></div>
|
||||
@if (overlayInformation | safeHtml; as info) {
|
||||
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}" *ngIf="info !== '' || info !== undefined">
|
||||
<div class="position-relative">
|
||||
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
|
||||
</div>
|
||||
@if (allowSelection) {
|
||||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)">
|
||||
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
<div>
|
||||
@if (count > 1) {
|
||||
<div class="count">
|
||||
<span class="badge bg-primary">{{count}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
@if (overlayInformation | safeHtml; as info) {
|
||||
@if (info !== '' || info !== null) {
|
||||
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}">
|
||||
<div class="position-relative">
|
||||
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (title.length > 0 || actions.length > 0) {
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
||||
<span *ngIf="isPromoted()">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">(promoted)</span>
|
||||
</span>
|
||||
<ng-container *ngIf="format | mangaFormat as formatString">
|
||||
<i class="fa {{format | mangaFormatIcon}} me-1" aria-hidden="true" *ngIf="format !== MangaFormat.UNKNOWN" title="{{formatString}}"></i>
|
||||
<span class="visually-hidden">{{formatString}}</span>
|
||||
</ng-container>
|
||||
<app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
|
||||
<app-series-format [format]="format"></app-series-format>
|
||||
{{title}}
|
||||
</span>
|
||||
<span class="card-actions float-end" *ngIf="actions && actions.length > 0">
|
||||
<span class="card-actions float-end" *ngIf="actions && actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
</span>
|
||||
</div>
|
||||
@if (subtitleTemplate) {
|
||||
<div style="text-align: center">
|
||||
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (!suppressLibraryLink && libraryName) {
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active">
|
||||
{{libraryName | sentenceCase}}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<span class="card-title library" [ngbTooltip]="subtitle" placement="top" *ngIf="subtitle.length > 0">{{subtitle}}</span>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!suppressLibraryLink && libraryName">
|
||||
{{libraryName | sentenceCase}}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component, ContentChild, DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
Output, TemplateRef
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { UserCollection } from 'src/app/_models/collection-tag';
|
||||
import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
||||
|
@ -44,6 +44,8 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
|
|||
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
|
@ -62,7 +64,9 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
|||
RouterLink,
|
||||
TranslocoModule,
|
||||
SafeHtmlPipe,
|
||||
RouterLinkActive
|
||||
RouterLinkActive,
|
||||
PromotedIconComponent,
|
||||
SeriesFormatComponent
|
||||
],
|
||||
templateUrl: './card-item.component.html',
|
||||
styleUrls: ['./card-item.component.scss'],
|
||||
|
@ -81,6 +85,7 @@ export class CardItemComponent implements OnInit {
|
|||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
/**
|
||||
|
@ -91,10 +96,6 @@ export class CardItemComponent implements OnInit {
|
|||
* Name of the card
|
||||
*/
|
||||
@Input() title = '';
|
||||
/**
|
||||
* Shows below the title. Defaults to not visible
|
||||
*/
|
||||
@Input() subtitle = '';
|
||||
/**
|
||||
* Any actions to perform on the card
|
||||
*/
|
||||
|
@ -114,7 +115,7 @@ export class CardItemComponent implements OnInit {
|
|||
/**
|
||||
* This is the entity we are representing. It will be returned if an action is executed.
|
||||
*/
|
||||
@Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
|
||||
@Input({required: true}) entity!: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
|
@ -147,6 +148,7 @@ export class CardItemComponent implements OnInit {
|
|||
* When the card is selected.
|
||||
*/
|
||||
@Output() selection = new EventEmitter<boolean>();
|
||||
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
|
||||
/**
|
||||
* Library name item belongs to
|
||||
*/
|
||||
|
@ -351,7 +353,7 @@ export class CardItemComponent implements OnInit {
|
|||
|
||||
|
||||
isPromoted() {
|
||||
const tag = this.entity as CollectionTag;
|
||||
const tag = this.entity as UserCollection;
|
||||
return tag.hasOwnProperty('promoted') && tag.promoted;
|
||||
}
|
||||
|
||||
|
@ -378,5 +380,10 @@ export class CardItemComponent implements OnInit {
|
|||
// this.actions = this.actions.filter(a => a.title !== 'Send To');
|
||||
// }
|
||||
}
|
||||
|
||||
// this.actions = this.actions.filter(a => {
|
||||
// if (!a.isAllowed) return true;
|
||||
// return a.isAllowed(a, this.entity);
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
<ng-container>
|
||||
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<app-icon-and-title [label]="t('format-title')" [clickable]="true"
|
||||
[fontClasses]="'fa ' + (series.format | mangaFormatIcon)"
|
||||
[fontClasses]="series.format | mangaFormatIcon"
|
||||
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
|
||||
{{series.format | mangaFormat}}
|
||||
</app-icon-and-title>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<h2 title>{{t('title')}}</h2>
|
||||
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="collections"
|
||||
|
@ -13,12 +14,21 @@
|
|||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions"
|
||||
[imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||
(clicked)="loadCollection(item)"></app-card-item>
|
||||
(clicked)="loadCollection(item)"
|
||||
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true">
|
||||
|
||||
<ng-template #subtitle>
|
||||
<app-collection-owner [collection]="item"></app-collection-owner>
|
||||
</ng-template>
|
||||
</app-card-item>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
{{t('no-data')}}
|
||||
<ng-container *ngIf="isAdmin$ | async"> {{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a></ng-container>
|
||||
@if(isAdmin$ | async) {
|
||||
{{t('create-one-part-1')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/collections" rel="noopener noreferrer" target="_blank">{{t('create-one-part-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
|
||||
}
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
|
@ -13,7 +14,7 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
|||
import {map, of} from 'rxjs';
|
||||
import {Observable} from 'rxjs/internal/Observable';
|
||||
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import {CollectionTag} from 'src/app/_models/collection-tag';
|
||||
import {UserCollection} from 'src/app/_models/collection-tag';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Tag} from 'src/app/_models/tag';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
|
@ -22,14 +23,24 @@ import {CollectionTagService} from 'src/app/_services/collection-tag.service';
|
|||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {JumpbarService} from 'src/app/_services/jumpbar.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
|
||||
import {AsyncPipe, DecimalPipe} from '@angular/common';
|
||||
import {CardItemComponent} from '../../../cards/card-item/card-item.component';
|
||||
import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
|
||||
import {CollectionOwnerComponent} from "../collection-owner/collection-owner.component";
|
||||
import {User} from "../../../_models/user";
|
||||
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
|
||||
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
|
||||
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
|
||||
import {ActionService} from "../../../_services/action.service";
|
||||
import {KEY_CODES} from "../../../shared/_services/utility.service";
|
||||
|
||||
|
||||
@Component({
|
||||
|
@ -38,51 +49,87 @@ import {ToastrService} from "ngx-toastr";
|
|||
styleUrls: ['./all-collections.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, NgIf, AsyncPipe, DecimalPipe, TranslocoDirective]
|
||||
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, AsyncPipe, DecimalPipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, BulkOperationsComponent, SeriesCardComponent]
|
||||
})
|
||||
export class AllCollectionsComponent implements OnInit {
|
||||
|
||||
isLoading: boolean = true;
|
||||
collections: CollectionTag[] = [];
|
||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
trackByIdentity = (index: number, item: CollectionTag) => `${item.id}_${item.title}`;
|
||||
isAdmin$: Observable<boolean> = of(false);
|
||||
|
||||
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
public readonly imageService = inject(ImageService);
|
||||
public readonly accountService = inject(AccountService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
public readonly actionService = inject(ActionService);
|
||||
|
||||
constructor(private collectionService: CollectionTagService, private router: Router,
|
||||
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
|
||||
private titleService: Title, private jumpbarService: JumpbarService,
|
||||
private readonly cdRef: ChangeDetectorRef, public imageService: ImageService,
|
||||
public accountService: AccountService) {
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
|
||||
isLoading: boolean = true;
|
||||
collections: UserCollection[] = [];
|
||||
collectionTagActions: ActionItem<UserCollection>[] = [];
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
isAdmin$: Observable<boolean> = of(false);
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
trackByIdentity = (index: number, item: UserCollection) => `${item.id}_${item.title}`;
|
||||
user!: User;
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor() {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title'));
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadPage();
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
if (!user) return;
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
|
||||
.filter(action => this.collectionService.actionListFilter(action, user));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(user => {
|
||||
if (!user) return false;
|
||||
return this.accountService.hasAdminRole(user);
|
||||
}));
|
||||
}
|
||||
|
||||
loadCollection(item: CollectionTag) {
|
||||
loadCollection(item: UserCollection) {
|
||||
this.router.navigate(['collections', item.id]);
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.collectionService.allTags().subscribe(tags => {
|
||||
this.collectionService.allCollections().subscribe(tags => {
|
||||
this.collections = [...tags];
|
||||
this.isLoading = false;
|
||||
this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title);
|
||||
|
@ -90,8 +137,20 @@ export class AllCollectionsComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
|
||||
handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
|
||||
|
||||
if (collectionTag.owner != this.user.username) {
|
||||
this.toastr.error(translate('toasts.collection-not-owned'));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.action) {
|
||||
case Action.Promote:
|
||||
this.collectionService.promoteMultipleCollections([collectionTag.id], true).subscribe();
|
||||
break;
|
||||
case Action.UnPromote:
|
||||
this.collectionService.promoteMultipleCollections([collectionTag.id], false).subscribe();
|
||||
break;
|
||||
case(Action.Delete):
|
||||
this.collectionService.deleteTag(collectionTag.id).subscribe(res => {
|
||||
this.toastr.success(res);
|
||||
|
@ -110,4 +169,33 @@ export class AllCollectionsComponent implements OnInit {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
const selectedCollectionIndexies = this.bulkSelectionService.getSelectedCardsForSource('collection');
|
||||
const selectedCollections = this.collections.filter((col, index: number) => selectedCollectionIndexies.includes(index + ''));
|
||||
|
||||
switch (action.action) {
|
||||
case Action.Promote:
|
||||
this.actionService.promoteMultipleCollections(selectedCollections, true, (success) => {
|
||||
if (!success) return;
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.UnPromote:
|
||||
this.actionService.promoteMultipleCollections(selectedCollections, false, (success) => {
|
||||
if (!success) return;
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
this.actionService.deleteMultipleCollections(selectedCollections, (successful) => {
|
||||
if (!successful) return;
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection
|
|||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {CollectionTag} from 'src/app/_models/collection-tag';
|
||||
import {UserCollection} from 'src/app/_models/collection-tag';
|
||||
import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
|
@ -52,6 +52,8 @@ import {CardActionablesComponent} from "../../../_single-module/card-actionables
|
|||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {User} from "../../../_models/user";
|
||||
|
||||
@Component({
|
||||
selector: 'app-collection-detail',
|
||||
|
@ -63,20 +65,41 @@ import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
|||
})
|
||||
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
|
||||
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||
|
||||
destroyRef = inject(DestroyRef);
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
collectionTag!: CollectionTag;
|
||||
|
||||
collectionTag!: UserCollection;
|
||||
isLoading: boolean = true;
|
||||
series: Array<Series> = [];
|
||||
pagination: Pagination = new Pagination();
|
||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||
collectionTagActions: ActionItem<UserCollection>[] = [];
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
summary: string = '';
|
||||
user!: User;
|
||||
|
||||
actionInProgress: boolean = false;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
|
@ -153,12 +176,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
|
||||
}
|
||||
|
||||
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
|
||||
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
|
||||
private modalService: NgbModal, private titleService: Title, private jumpbarService: JumpbarService,
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
|
||||
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
|
||||
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
const routeId = this.route.snapshot.paramMap.get('id');
|
||||
|
@ -184,7 +203,14 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
if (!user) return;
|
||||
this.user = user;
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this))
|
||||
.filter(action => this.collectionService.actionListFilter(action, user));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => {
|
||||
if (event.event == EVENTS.SeriesAddedToCollection) {
|
||||
|
@ -217,11 +243,10 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
|
||||
updateTag(tagId: number) {
|
||||
this.collectionService.allTags().subscribe(tags => {
|
||||
this.collectionService.allCollections().subscribe(tags => {
|
||||
const matchingTags = tags.filter(t => t.id === tagId);
|
||||
if (matchingTags.length === 0) {
|
||||
this.toastr.error(this.translocoService.translate('errors.collection-invalid-access'));
|
||||
// TODO: Why would access need to be checked? Even if a id was guessed, the series wouldn't return
|
||||
this.router.navigateByUrl('/');
|
||||
return;
|
||||
}
|
||||
|
@ -261,8 +286,18 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
});
|
||||
}
|
||||
|
||||
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
|
||||
handleCollectionActionCallback(action: ActionItem<UserCollection>, collectionTag: UserCollection) {
|
||||
if (collectionTag.owner != this.user.username) {
|
||||
this.toastr.error(translate('toasts.collection-not-owned'));
|
||||
return;
|
||||
}
|
||||
switch (action.action) {
|
||||
case Action.Promote:
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], true).subscribe();
|
||||
break;
|
||||
case Action.UnPromote:
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], false).subscribe();
|
||||
break;
|
||||
case(Action.Edit):
|
||||
this.openEditCollectionTagModal(this.collectionTag);
|
||||
break;
|
||||
|
@ -283,7 +318,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
}
|
||||
|
||||
openEditCollectionTagModal(collectionTag: CollectionTag) {
|
||||
openEditCollectionTagModal(collectionTag: UserCollection) {
|
||||
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
|
||||
modalRef.componentInstance.tag = this.collectionTag;
|
||||
modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
|
||||
|
@ -291,6 +326,4 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly undefined = undefined;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<ng-container *transloco="let t; read: 'collection-owner'">
|
||||
@if (accountService.currentUser$ | async; as user) {
|
||||
<div class="fw-light text-accent">
|
||||
{{t('collection-created-label', {owner: collection.owner})}}
|
||||
@if(collection.source !== ScrobbleProvider.Kavita) {
|
||||
{{t('collection-via-label')}}
|
||||
<app-image [imageUrl]="collection.source | providerImage"
|
||||
width="16px" height="16px"
|
||||
[ngbTooltip]="collection.source | providerName"
|
||||
[attr.aria-label]="collection.source | providerName"></app-image>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
|
@ -0,0 +1,4 @@
|
|||
.text-accent {
|
||||
font-size: small;
|
||||
color: var(---accent-text-color);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
|
||||
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
|
||||
import {UserCollection} from "../../../_models/collection-tag";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {AsyncPipe, JsonPipe} from "@angular/common";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-collection-owner',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ProviderImagePipe,
|
||||
ProviderNamePipe,
|
||||
TranslocoDirective,
|
||||
AsyncPipe,
|
||||
JsonPipe,
|
||||
ImageComponent,
|
||||
NgbTooltip
|
||||
],
|
||||
templateUrl: './collection-owner.component.html',
|
||||
styleUrl: './collection-owner.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CollectionOwnerComponent {
|
||||
|
||||
protected readonly accountService = inject(AccountService);
|
||||
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
|
||||
@Input({required: true}) collection!: UserCollection;
|
||||
}
|
|
@ -287,7 +287,7 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||
return {value: status.id, label: status.title}
|
||||
})));
|
||||
case FilterField.CollectionTags:
|
||||
return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => {
|
||||
return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => {
|
||||
return {value: status.id, label: status.title}
|
||||
})));
|
||||
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
|
||||
|
|
|
@ -74,11 +74,11 @@
|
|||
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
|
||||
</div>
|
||||
<div class="ms-1">
|
||||
<span>{{item.title}}</span>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||
<span class="visually-hidden">{{t('promoted')}}</span>
|
||||
</span>
|
||||
<div>
|
||||
<span>{{item.title}}</span>
|
||||
<app-promoted-icon [promoted]="item.promoted"></app-promoted-icon>
|
||||
</div>
|
||||
<app-collection-owner [collection]="item"></app-collection-owner>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@ -88,7 +88,7 @@
|
|||
<div class="ms-1">
|
||||
<span>{{item.title}}</span>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" [title]="t('promoted')"></i>
|
||||
<span class="visually-hidden">{{t('promoted')}}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/rout
|
|||
import {fromEvent} from 'rxjs';
|
||||
import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {CollectionTag} from 'src/app/_models/collection-tag';
|
||||
import {UserCollection} from 'src/app/_models/collection-tag';
|
||||
import {Library} from 'src/app/_models/library/library';
|
||||
import {MangaFile} from 'src/app/_models/manga-file';
|
||||
import {PersonRole} from 'src/app/_models/metadata/person';
|
||||
|
@ -40,6 +40,11 @@ import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
|||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {BookmarkSearchResult} from "../../../_models/search/bookmark-search-result";
|
||||
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
|
||||
import {CollectionOwnerComponent} from "../../../collections/_components/collection-owner/collection-owner.component";
|
||||
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-header',
|
||||
|
@ -47,7 +52,9 @@ import {BookmarkSearchResult} from "../../../_models/search/bookmark-search-resu
|
|||
styleUrls: ['./nav-header.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent, SeriesFormatComponent, EventsWidgetComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, AsyncPipe, PersonRolePipe, SentenceCasePipe, TranslocoDirective]
|
||||
imports: [NgIf, RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent,
|
||||
SeriesFormatComponent, EventsWidgetComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
|
||||
AsyncPipe, PersonRolePipe, SentenceCasePipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, PromotedIconComponent]
|
||||
})
|
||||
export class NavHeaderComponent implements OnInit {
|
||||
|
||||
|
@ -242,7 +249,7 @@ export class NavHeaderComponent implements OnInit {
|
|||
this.router.navigate(['library', item.id]);
|
||||
}
|
||||
|
||||
clickCollectionSearchResult(item: CollectionTag) {
|
||||
clickCollectionSearchResult(item: UserCollection) {
|
||||
this.clearSearch();
|
||||
this.router.navigate(['collections', item.id]);
|
||||
}
|
||||
|
@ -267,4 +274,5 @@ export class NavHeaderComponent implements OnInit {
|
|||
}
|
||||
|
||||
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,6 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
isAdmin: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
accessibilityMode: boolean = false;
|
||||
hasDownloadingRole: boolean = false;
|
||||
readingListSummary: string = '';
|
||||
|
||||
libraryTypes: {[key: number]: LibraryType} = {};
|
||||
|
@ -114,7 +113,6 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
|
||||
|
||||
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
|
||||
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
|
||||
|
|
|
@ -31,11 +31,7 @@
|
|||
|
||||
</h5>
|
||||
<div class="ps-1 d-none d-md-inline-block">
|
||||
<ng-container *ngIf="item.seriesFormat | mangaFormat as formatString">
|
||||
<i class="fa {{item.seriesFormat | mangaFormatIcon}}" aria-hidden="true" *ngIf="item.seriesFormat !== MangaFormat.UNKNOWN" title="{{formatString}}"></i>
|
||||
<span class="visually-hidden">{{formatString}}</span>
|
||||
</ng-container>
|
||||
|
||||
<app-series-format [format]="item.seriesFormat"></app-series-format>
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { NgbProgressbar } from '@ng-bootstrap/ng-bootstrap';
|
|||
import { NgIf, DatePipe } from '@angular/common';
|
||||
import { ImageComponent } from '../../../shared/image/image.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-list-item',
|
||||
|
@ -16,7 +17,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||
styleUrls: ['./reading-list-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [ImageComponent, NgIf, NgbProgressbar, DatePipe, MangaFormatPipe, MangaFormatIconPipe, TranslocoDirective]
|
||||
imports: [ImageComponent, NgIf, NgbProgressbar, DatePipe, MangaFormatPipe, MangaFormatIconPipe, TranslocoDirective, SeriesFormatComponent]
|
||||
})
|
||||
export class ReadingListItemComponent {
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ export class MetadataDetailComponent {
|
|||
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
|
|
|
@ -35,13 +35,16 @@
|
|||
</app-metadata-detail>
|
||||
|
||||
<!-- Collections -->
|
||||
<app-metadata-detail [tags]="seriesMetadata.collectionTags" [libraryId]="series.libraryId" [heading]="t('collections-title')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="navigate('collections', item.id)" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
@if (collections$) {
|
||||
<app-metadata-detail [tags]="(collections$ | async)!" [libraryId]="series.libraryId" [heading]="t('collections-title')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="navigate('collections', item.id)" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
<app-promoted-icon [promoted]="item.promoted"></app-promoted-icon> {{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
}
|
||||
|
||||
|
||||
<!-- Reading Lists -->
|
||||
<app-metadata-detail [tags]="readingLists" [libraryId]="series.libraryId" [heading]="t('reading-lists-title')">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Component, DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges, OnInit,
|
||||
|
@ -33,6 +33,12 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
|||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {Rating} from "../../../_models/rating";
|
||||
import {CollectionTagService} from "../../../_services/collection-tag.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
import {Observable} from "rxjs";
|
||||
import {UserCollection} from "../../../_models/collection-tag";
|
||||
|
||||
|
||||
@Component({
|
||||
|
@ -40,7 +46,7 @@ import {Rating} from "../../../_models/rating";
|
|||
standalone: true,
|
||||
imports: [CommonModule, TagBadgeComponent, BadgeExpanderComponent, SafeHtmlPipe, ExternalRatingComponent,
|
||||
ReadMoreComponent, A11yClickDirective, PersonBadgeComponent, NgbCollapse, SeriesInfoCardsComponent,
|
||||
MetadataDetailComponent, TranslocoDirective, ImageComponent],
|
||||
MetadataDetailComponent, TranslocoDirective, ImageComponent, PromotedIconComponent],
|
||||
templateUrl: './series-metadata-detail.component.html',
|
||||
styleUrls: ['./series-metadata-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
@ -53,6 +59,8 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
|
|||
private readonly router = inject(Router);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly collectionTagService = inject(CollectionTagService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly FilterField = FilterField;
|
||||
protected readonly LibraryType = LibraryType;
|
||||
|
@ -77,6 +85,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
|
|||
* Html representation of Series Summary
|
||||
*/
|
||||
seriesSummary: string = '';
|
||||
collections$: Observable<UserCollection[]> | undefined;
|
||||
|
||||
get WebLinks() {
|
||||
if (this.seriesMetadata?.webLinks === '') return [];
|
||||
|
@ -96,7 +105,12 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
|
|||
if (sum > 10) {
|
||||
this.isCollapsed = true;
|
||||
}
|
||||
|
||||
this.collections$ = this.collectionTagService.allCollectionsForSeries(this.series.id).pipe(
|
||||
takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<ng-container *transloco="let t; read: 'promoted-icon'">
|
||||
@if(promoted) {
|
||||
<span>
|
||||
<i class="fa fa-angle-double-up ms-1" aria-hidden="true" title="Promoted"></i>
|
||||
<span class="visually-hidden">{{t('promoted')}}</span>
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
|
@ -0,0 +1,16 @@
|
|||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-promoted-icon',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './promoted-icon.component.html',
|
||||
styleUrl: './promoted-icon.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PromotedIconComponent {
|
||||
@Input({required: true}) promoted: boolean = false;
|
||||
}
|
|
@ -23,7 +23,7 @@ import {translate} from "@ngneat/transloco";
|
|||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SAVER, Saver} from "../../_providers/saver.provider";
|
||||
import {UtilityService} from "./utility.service";
|
||||
import {CollectionTag} from "../../_models/collection-tag";
|
||||
import {UserCollection} from "../../_models/collection-tag";
|
||||
import {RecentlyAddedItem} from "../../_models/recently-added-item";
|
||||
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
|
||||
|
||||
|
@ -359,7 +359,7 @@ export class DownloadService {
|
|||
}
|
||||
}
|
||||
|
||||
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
|
||||
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
|
||||
if(this.utilityService.isSeries(entity)) {
|
||||
return events.find(e => e.entityType === 'series' && e.id == entity.id
|
||||
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<ng-container *ngIf="format !== MangaFormat.UNKNOWN">
|
||||
<i class="fa {{format | mangaFormatIcon}}" aria-hidden="true" title="{{format | mangaFormat}}"></i>
|
||||
<i class="{{format | mangaFormatIcon}} me-1" aria-hidden="true" title="{{format | mangaFormat}}"></i>
|
||||
<ng-content></ng-content>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
|
@ -18,9 +18,7 @@ import {CommonModule} from "@angular/common";
|
|||
})
|
||||
export class SeriesFormatComponent {
|
||||
|
||||
@Input() format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
@Input() format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
}
|
||||
|
|
|
@ -1381,9 +1381,9 @@
|
|||
"promote-label": "Promote",
|
||||
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.",
|
||||
"summary-label": "Summary",
|
||||
"series-title": "Applies to Series",
|
||||
"deselect-all": "{{common.deselect-all}}",
|
||||
"select-all": "{{common.select-all}}"
|
||||
"select-all": "{{common.select-all}}",
|
||||
"filter-label": "{{common.filter}}"
|
||||
},
|
||||
|
||||
"library-detail": {
|
||||
|
@ -1527,7 +1527,7 @@
|
|||
"skip-alt": "Skip to main content",
|
||||
"search-series-alt": "Search series",
|
||||
"search-alt": "Search…",
|
||||
"promoted": "(promoted)",
|
||||
"promoted": "{{common.promoted}}",
|
||||
"no-data": "No results found",
|
||||
"scroll-to-top-alt": "Scroll to Top",
|
||||
"server-settings": "Server Settings",
|
||||
|
@ -1538,11 +1538,20 @@
|
|||
"all-filters": "Smart Filters"
|
||||
},
|
||||
|
||||
"promoted-icon": {
|
||||
"promoted": "{{common.promoted}}"
|
||||
},
|
||||
|
||||
"collection-owner": {
|
||||
"collection-created-label": "Created by: {{owner}}",
|
||||
"collection-via-label": "via {{source}}"
|
||||
},
|
||||
|
||||
"add-to-list-modal": {
|
||||
"title": "Add to Reading List",
|
||||
"close": "{{common.close}}",
|
||||
"filter-label": "{{common.filter}}",
|
||||
"promoted-alt": "Promoted",
|
||||
"promoted-alt": "{{common.promoted}}",
|
||||
"no-data": "No lists created yet",
|
||||
"loading": "{{common.loading}}",
|
||||
"reading-list-label": "Reading List",
|
||||
|
@ -1565,9 +1574,11 @@
|
|||
"ending-title": "Ending",
|
||||
"starting-title": "Starting",
|
||||
"promote-label": "Promote",
|
||||
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them."
|
||||
"promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them."
|
||||
},
|
||||
|
||||
|
||||
|
||||
"import-mal-collection-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"title": "MAL Interest Stack Import",
|
||||
|
@ -1748,7 +1759,6 @@
|
|||
"cover-image-tab": "Cover Image",
|
||||
"related-tab": "Related",
|
||||
"info-tab": "Info",
|
||||
"collections-label": "Collections",
|
||||
"genres-label": "Genres",
|
||||
"tags-label": "Tags",
|
||||
"cover-artist-label": "Cover Artist",
|
||||
|
@ -2162,7 +2172,13 @@
|
|||
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
|
||||
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account",
|
||||
"collection-tag-deleted": "Collection Tag deleted",
|
||||
"force-kavita+-refresh-success": "Kavita+ external metadata has been invalidated"
|
||||
"force-kavita+-refresh-success": "Kavita+ external metadata has been invalidated",
|
||||
"collection-not-owned": "You do not own this collection",
|
||||
"collections-promoted": "Collections promoted",
|
||||
"collections-unpromoted": "Collections un-promoted",
|
||||
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
|
||||
"collections-deleted": "Collections deleted"
|
||||
|
||||
},
|
||||
|
||||
"actionable": {
|
||||
|
@ -2196,7 +2212,9 @@
|
|||
"remove-rule-group": "Remove Rule Group",
|
||||
"customize": "Customize",
|
||||
"mark-visible": "Mark as Visible",
|
||||
"mark-invisible": "Mark as Invisible"
|
||||
"mark-invisible": "Mark as Invisible",
|
||||
"unpromote": "Un-Promote",
|
||||
"promote": "Promote"
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
|
|
|
@ -181,6 +181,7 @@
|
|||
|
||||
/* Rating star */
|
||||
--ratingstar-color: white;
|
||||
--rating-star-color: var(--primary-color);
|
||||
--ratingstar-star-empty: #b0c4de;
|
||||
--ratingstar-star-filled: var(--primary-color);
|
||||
|
||||
|
@ -257,9 +258,6 @@
|
|||
--review-spoiler-bg-color: var(--primary-color);
|
||||
--review-spoiler-text-color: var(--body-text-color);
|
||||
|
||||
/** Rating Star Color **/
|
||||
--rating-star-color: var(--primary-color);
|
||||
|
||||
/** Badge **/
|
||||
--badge-text-color: var(--bs-badge-color);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue