Collection Rework (#2830)

This commit is contained in:
Joe Milazzo 2024-04-06 12:03:49 -05:00 committed by GitHub
parent 0dacc061f1
commit deaaccb96a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 5413 additions and 1120 deletions

View file

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

View file

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

View file

@ -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';
}
}

View file

@ -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');
}

View file

@ -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: [],
},
],

View file

@ -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' });

View file

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

View file

@ -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);
}

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

@ -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();
}
}

View file

@ -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' : ''}}">

View file

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

View file

@ -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);
}

View file

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

View file

@ -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);
// });
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
.text-accent {
font-size: small;
color: var(---accent-text-color);
}

View file

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

View file

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

View file

@ -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">
&nbsp;<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">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" [title]="t('promoted')"></i>
<span class="visually-hidden">{{t('promoted')}}</span>
</span>
</div>

View file

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

View file

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

View file

@ -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>&nbsp;
</ng-container>
<app-series-format [format]="item.seriesFormat"></app-series-format>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
</div>

View file

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

View file

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

View file

@ -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')">

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
<ng-container *ngIf="format !== MangaFormat.UNKNOWN">
<i class="fa {{format | mangaFormatIcon}}" aria-hidden="true" title="{{format | mangaFormat}}"></i>&nbsp;
<i class="{{format | mangaFormatIcon}} me-1" aria-hidden="true" title="{{format | mangaFormat}}"></i>
<ng-content></ng-content>
</ng-container>
</ng-container>

View file

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

View file

@ -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": {

View file

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