Random Stuff (#3798)

This commit is contained in:
Joe Milazzo 2025-05-10 15:57:14 -06:00 committed by GitHub
parent 574cf4b78e
commit 70f00895e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 659 additions and 567 deletions

View file

@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list';
import {Series} from '../_models/series';
import {Volume} from '../_models/volume';
import {AccountService} from './account.service';
import {AccountService, Role} from './account.service';
import {DeviceService} from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person";
import {User} from '../_models/user';
export enum Action {
Submenu = -1,
@ -106,7 +107,7 @@ export enum Action {
Promote = 24,
UnPromote = 25,
/**
* Invoke a refresh covers as false to generate colorscapes
* Invoke refresh covers as false to generate colorscapes
*/
GenerateColorScape = 26,
/**
@ -126,14 +127,21 @@ export enum Action {
/**
* Callback for an action
*/
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
export interface ActionItem<T> {
title: string;
description: string;
action: Action;
callback: ActionCallback<T>;
/**
* Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply.
*/
requiredRoles: Role[];
/**
* @deprecated Use required Roles instead
*/
requiresAdmin: boolean;
children: Array<ActionItem<T>>;
/**
@ -149,94 +157,98 @@ export interface ActionItem<T> {
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
*/
_extra?: {title: string, data: any};
/**
* Will call on each action to determine if it should show for the appropriate entity based on state and user
*/
shouldRender: ActionShouldRenderFunc<T>;
}
/**
* Entities that can be actioned upon
*/
export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null;
@Injectable({
providedIn: 'root',
})
export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = [];
seriesActions: Array<ActionItem<Series>> = [];
volumeActions: Array<ActionItem<Volume>> = [];
chapterActions: Array<ActionItem<Chapter>> = [];
collectionTagActions: Array<ActionItem<UserCollection>> = [];
readingListActions: Array<ActionItem<ReadingList>> = [];
bookmarkActions: Array<ActionItem<Series>> = [];
private libraryActions: Array<ActionItem<Library>> = [];
private seriesActions: Array<ActionItem<Series>> = [];
private volumeActions: Array<ActionItem<Volume>> = [];
private chapterActions: Array<ActionItem<Chapter>> = [];
private collectionTagActions: Array<ActionItem<UserCollection>> = [];
private readingListActions: Array<ActionItem<ReadingList>> = [];
private bookmarkActions: Array<ActionItem<Series>> = [];
private personActions: Array<ActionItem<Person>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
sideNavHomeActions: Array<ActionItem<void>> = [];
isAdmin = false;
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
private sideNavHomeActions: Array<ActionItem<void>> = [];
constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
} else {
this._resetActions();
return; // If user is logged out, we don't need to do anything
}
this.accountService.currentUser$.subscribe((_) => {
this._resetActions();
});
}
getLibraryActions(callback: ActionCallback<Library>) {
return this.applyCallbackToList(this.libraryActions, callback);
getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
}
getSeriesActions(callback: ActionCallback<Series>) {
return this.applyCallbackToList(this.seriesActions, callback);
getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
}
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
return this.applyCallbackToList(this.sideNavStreamActions, callback);
getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
}
getSmartFilterActions(callback: ActionCallback<SmartFilter>) {
return this.applyCallbackToList(this.smartFilterActions, callback);
getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
}
getVolumeActions(callback: ActionCallback<Volume>) {
return this.applyCallbackToList(this.volumeActions, callback);
getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
}
getChapterActions(callback: ActionCallback<Chapter>) {
return this.applyCallbackToList(this.chapterActions, callback);
getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
}
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
return this.applyCallbackToList(this.collectionTagActions, callback);
getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
}
getReadingListActions(callback: ActionCallback<ReadingList>) {
return this.applyCallbackToList(this.readingListActions, callback);
getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
}
getBookmarkActions(callback: ActionCallback<Series>) {
return this.applyCallbackToList(this.bookmarkActions, callback);
getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
}
getPersonActions(callback: ActionCallback<Person>) {
return this.applyCallbackToList(this.personActions, callback);
getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
}
getSideNavHomeActions(callback: ActionCallback<void>) {
return this.applyCallbackToList(this.sideNavHomeActions, callback);
getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc);
}
dummyCallback(action: ActionItem<any>, data: any) {}
dummyCallback(action: ActionItem<any>, entity: any) {}
dummyShouldRender(action: ActionItem<any>, entity: any, user: User) {return true;}
basicReadRender(action: ActionItem<any>, entity: any, user: User) {
if (entity === null || entity === undefined) return true;
if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true;
switch (action.action) {
case(Action.MarkAsRead):
return entity.pagesRead < entity.pages;
case(Action.MarkAsUnread):
return entity.pagesRead !== 0;
default:
return true;
}
}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
@ -279,7 +291,7 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action));
}
getBulkLibraryActions(callback: ActionCallback<Library>) {
getBulkLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
// Scan is currently not supported due to the backend not being able to handle it yet
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
@ -293,11 +305,13 @@ export class ActionFactoryService {
dynamicList: undefined,
action: Action.CopySettings,
callback: this.dummyCallback,
shouldRender: shouldRenderFunc,
children: [],
requiredRoles: [Role.Admin],
requiresAdmin: true,
title: 'copy-settings'
})
return this.applyCallbackToList(actions, callback);
return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem<Library>[];
}
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
@ -323,7 +337,9 @@ export class ActionFactoryService {
title: 'scan-library',
description: 'scan-library-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -331,14 +347,18 @@ export class ActionFactoryService {
title: 'others',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [
{
action: Action.RefreshMetadata,
title: 'refresh-covers',
description: 'refresh-covers-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -346,7 +366,9 @@ export class ActionFactoryService {
title: 'generate-colorscape',
description: 'generate-colorscape-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -354,7 +376,9 @@ export class ActionFactoryService {
title: 'analyze-files',
description: 'analyze-files-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -362,7 +386,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
],
@ -372,7 +398,9 @@ export class ActionFactoryService {
title: 'settings',
description: 'settings-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
];
@ -383,7 +411,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -391,7 +421,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
class: 'danger',
children: [],
},
@ -400,7 +432,9 @@ export class ActionFactoryService {
title: 'promote',
description: 'promote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -408,7 +442,9 @@ export class ActionFactoryService {
title: 'unpromote',
description: 'unpromote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -419,7 +455,9 @@ export class ActionFactoryService {
title: 'mark-as-read',
description: 'mark-as-read-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -427,7 +465,9 @@ export class ActionFactoryService {
title: 'mark-as-unread',
description: 'mark-as-unread-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -435,7 +475,9 @@ export class ActionFactoryService {
title: 'scan-series',
description: 'scan-series-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -443,14 +485,18 @@ export class ActionFactoryService {
title: 'add-to',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.AddToWantToReadList,
title: 'add-to-want-to-read',
description: 'add-to-want-to-read-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -458,7 +504,9 @@ export class ActionFactoryService {
title: 'remove-from-want-to-read',
description: 'remove-to-want-to-read-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -466,7 +514,9 @@ export class ActionFactoryService {
title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -474,26 +524,11 @@ export class ActionFactoryService {
title: 'add-to-collection',
description: 'add-to-collection-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
// {
// action: Action.AddToScrobbleHold,
// title: 'add-to-scrobble-hold',
// description: 'add-to-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
// {
// action: Action.RemoveFromScrobbleHold,
// title: 'remove-from-scrobble-hold',
// description: 'remove-from-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
],
},
{
@ -501,14 +536,18 @@ export class ActionFactoryService {
title: 'send-to',
description: 'send-to-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.SendTo,
title: '',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d};
}), shareReplay())),
@ -521,14 +560,18 @@ export class ActionFactoryService {
title: 'others',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [],
children: [
{
action: Action.RefreshMetadata,
title: 'refresh-covers',
description: 'refresh-covers-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -536,7 +579,9 @@ export class ActionFactoryService {
title: 'generate-colorscape',
description: 'generate-colorscape-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -544,7 +589,9 @@ export class ActionFactoryService {
title: 'analyze-files',
description: 'analyze-files-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -552,7 +599,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
class: 'danger',
children: [],
},
@ -563,7 +612,9 @@ export class ActionFactoryService {
title: 'match',
description: 'match-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -571,7 +622,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [Role.Download],
children: [],
},
{
@ -579,7 +632,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
];
@ -590,7 +645,9 @@ export class ActionFactoryService {
title: 'read-incognito',
description: 'read-incognito-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -598,7 +655,9 @@ export class ActionFactoryService {
title: 'mark-as-read',
description: 'mark-as-read-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -606,7 +665,9 @@ export class ActionFactoryService {
title: 'mark-as-unread',
description: 'mark-as-unread-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -614,14 +675,18 @@ export class ActionFactoryService {
title: 'add-to',
description: '=',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
}
]
@ -631,14 +696,18 @@ export class ActionFactoryService {
title: 'send-to',
description: 'send-to-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.SendTo,
title: '',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d};
}), shareReplay())),
@ -651,14 +720,18 @@ export class ActionFactoryService {
title: 'others',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.Delete,
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -666,7 +739,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
]
@ -676,7 +751,9 @@ export class ActionFactoryService {
title: 'details',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -687,7 +764,9 @@ export class ActionFactoryService {
title: 'read-incognito',
description: 'read-incognito-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -695,7 +774,9 @@ export class ActionFactoryService {
title: 'mark-as-read',
description: 'mark-as-read-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -703,7 +784,9 @@ export class ActionFactoryService {
title: 'mark-as-unread',
description: 'mark-as-unread-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -711,14 +794,18 @@ export class ActionFactoryService {
title: 'add-to',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
}
]
@ -728,14 +815,18 @@ export class ActionFactoryService {
title: 'send-to',
description: 'send-to-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.SendTo,
title: '',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d};
}), shareReplay())),
@ -749,14 +840,18 @@ export class ActionFactoryService {
title: 'others',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.Delete,
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -764,7 +859,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [Role.Download],
children: [],
},
]
@ -774,7 +871,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -785,7 +884,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -793,7 +894,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
class: 'danger',
children: [],
},
@ -802,7 +905,9 @@ export class ActionFactoryService {
title: 'promote',
description: 'promote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -810,7 +915,9 @@ export class ActionFactoryService {
title: 'unpromote',
description: 'unpromote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -821,7 +928,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-person-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -829,7 +938,9 @@ export class ActionFactoryService {
title: 'merge',
description: 'merge-person-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
}
];
@ -840,7 +951,9 @@ export class ActionFactoryService {
title: 'view-series',
description: 'view-series-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -848,7 +961,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -856,8 +971,10 @@ export class ActionFactoryService {
title: 'clear',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
class: 'danger',
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -868,7 +985,9 @@ export class ActionFactoryService {
title: 'mark-visible',
description: 'mark-visible-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -876,7 +995,9 @@ export class ActionFactoryService {
title: 'mark-invisible',
description: 'mark-invisible-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -887,7 +1008,9 @@ export class ActionFactoryService {
title: 'rename',
description: 'rename-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -895,7 +1018,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -906,7 +1031,9 @@ export class ActionFactoryService {
title: 'reorder',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
}
]
@ -914,21 +1041,24 @@ export class ActionFactoryService {
}
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
private applyCallback(action: ActionItem<any>, callback: ActionCallback<any>, shouldRenderFunc: ActionShouldRenderFunc<any>) {
action.callback = callback;
action.shouldRender = shouldRenderFunc;
if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => {
this.applyCallback(childAction, callback);
this.applyCallback(childAction, callback, shouldRenderFunc);
});
}
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
public applyCallbackToList(list: Array<ActionItem<any>>,
callback: ActionCallback<any>,
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});
actions.forEach((action) => this.applyCallback(action, callback));
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
return actions;
}

View file

@ -473,8 +473,7 @@ export class ActionService {
}
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
// TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes"
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
if (callback) {

View file

@ -1,20 +1,19 @@
import { HttpClient } from '@angular/common/http';
import {HttpClient, HttpParams} from '@angular/common/http';
import {Inject, inject, Injectable} from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
import {asyncScheduler, finalize, map, tap} from 'rxjs';
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads';
import { ReadHistoryEvent } from '../statistics/_models/read-history-event';
import { ServerStatistics } from '../statistics/_models/server-statistics';
import { StatCount } from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format';
import { TextResonse } from '../_types/text-response';
import {environment} from 'src/environments/environment';
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
import {asyncScheduler, map} from 'rxjs';
import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
import {TopUserRead} from '../statistics/_models/top-reads';
import {ReadHistoryEvent} from '../statistics/_models/read-history-event';
import {ServerStatistics} from '../statistics/_models/server-statistics';
import {StatCount} from '../statistics/_models/stat-count';
import {PublicationStatus} from '../_models/metadata/publication-status';
import {MangaFormat} from '../_models/manga-format';
import {TextResonse} from '../_types/text-response';
import {TranslocoService} from "@jsverse/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
import {throttleTime} from "rxjs/operators";
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {download} from "../shared/_models/download";
@ -44,11 +43,14 @@ export class StatisticsService {
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object
let url = 'stats/user/' + userId + '/read';
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
const url = `${this.baseUrl}stats/user/${userId}/read`;
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url);
let params = new HttpParams();
if (libraryIds.length > 0) {
params = params.set('libraryIds', libraryIds.join(','));
}
return this.httpClient.get<UserReadStatistics>(url, { params });
}
getServerStatistics() {
@ -59,7 +61,7 @@ export class StatisticsService {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
})));
}
getTopYears() {

View file

@ -1,7 +1,9 @@
<ng-container *transloco="let t; read: 'actionable'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4>
<h4 class="modal-title">
{{t('title')}}
</h4>
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div>
<div class="modal-body scrollable-modal">
@ -12,8 +14,6 @@
}
<div class="d-grid gap-2">
@for (action of currentItems; track action.title) {
@if (willRenderAction(action)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"

View file

@ -1,18 +1,18 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {NgClass} from "@angular/common";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {Action, ActionItem} from "../../_services/action-factory.service";
import {ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {AccountService} from "../../_services/account.service";
import {tap} from "rxjs";
import {User} from "../../_models/user";
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
protected readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint;
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;

View file

@ -1,51 +1,57 @@
<ng-container *transloco="let t; read: 'actionable'">
@if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
} @else {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
<ng-container *transloco="let t; read: 'actionable'">
@if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
} @else {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>
</div>
</div>
<ng-template #submenu let-list="list">
@for(action of list; track action.title) {
<!-- Non Submenu items -->
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
} @else if (willRenderAction(action)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)">
@if (willRenderAction(action)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
<ng-template #submenu let-list="list">
@for(action of list; track action.title) {
<!-- Non Submenu items -->
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
} @else if (willRenderAction(action, this.currentUser!)) {
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="openSubmenu(action.title, subMenuHover)"
(mouseenter)="openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event)"
class="submenu-wrapper">
<!-- Check to ensure the submenu has items -->
@if (willRenderAction(action, this.currentUser!)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>
{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i>
</button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div>
</div>
}
}
}
}
</ng-template>
</ng-template>
}
}
}
</ng-container>
</ng-container>

View file

@ -2,6 +2,22 @@
content: none !important;
}
.submenu-wrapper {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: -10px;
width: 10px;
height: 100%;
background: transparent;
cursor: pointer;
pointer-events: auto;
}
}
.submenu-toggle {
display: block;
width: 100%;
@ -30,9 +46,3 @@
.btn {
padding: 5px;
}
// Robbie added this but it broke most of the uses
//.dropdown-toggle {
// padding-top: 0;
// padding-bottom: 0;
//}

View file

@ -1,31 +1,39 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {AccountService} from 'src/app/_services/account.service';
import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {User} from "../../_models/user";
@Component({
selector: 'app-card-actionables',
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-card-actionables',
imports: [
NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardActionablesComponent implements OnInit {
export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
@Input() iconClass = 'fa-ellipsis-v';
@Input() btnClass = '';
@Input() actions: ActionItem<any>[] = [];
@Input() inputActions: ActionItem<any>[] = [];
@Input() labelBy = 'card';
/**
* Text to display as if actionable was a button
*/
@Input() label = '';
@Input() disabled: boolean = false;
@Input() entity: ActionableEntity = null;
/**
* This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked.
*/
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
isAdmin: boolean = false;
canDownload: boolean = false;
canPromote: boolean = false;
actions: ActionItem<ActionableEntity>[] = [];
currentUser: User | undefined = undefined;
submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
ngOnInit(): void {
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.currentUser = user;
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
this.cdRef.markForCheck();
});
}
ngOnChanges() {
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.cancelCloseSubmenus();
}
preventEvent(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(event: any, action: ActionItem<any>) {
performAction(event: any, action: ActionItem<ActionableEntity>) {
this.preventEvent(event);
if (typeof action.callback === 'function') {
this.actionHandler.emit(action);
if (this.entity === null) {
this.actionHandler.emit(action);
} else {
action.callback(action, this.entity);
}
}
}
willRenderAction(action: ActionItem<any>) {
return (action.requiresAdmin && this.isAdmin)
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|| (!action.requiresAdmin && action.action !== Action.Download)
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
;
/**
* The user has required roles (or no roles defined) and action shouldRender returns true
* @param action
* @param user
*/
willRenderAction(action: ActionItem<ActionableEntity>, user: User) {
return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity, user);
}
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
}
closeAllSubmenus() {
Object.keys(this.submenu).forEach(key => {
this.submenu[key].close();
// Clear any existing timeout to avoid race conditions
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
// Set a new timeout to close submenus after a short delay
this.closeTimeout = setTimeout(() => {
Object.keys(this.submenu).forEach(key => {
this.submenu[key].close();
delete this.submenu[key];
});
});
}, 100); // Small delay to prevent premature closing (dropdown tunneling)
}
performDynamicClick(event: any, action: ActionItem<any>, dynamicItem: any) {
cancelCloseSubmenus() {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
}
hasRenderableChildren(action: ActionItem<ActionableEntity>, user: User): boolean {
if (!action.children || action.children.length === 0) return false;
for (const child of action.children) {
const dynamicList = child.dynamicList;
if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded
if (this.willRenderAction(child, user)) return true;
if (child.children?.length && this.hasRenderableChildren(child, user)) return true;
}
return false;
}
performDynamicClick(event: any, action: ActionItem<ActionableEntity>, dynamicItem: any) {
action._extra = dynamicItem;
this.performAction(event, action);
}
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
this.preventEvent(event);
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
ref.componentInstance.entity = this.entity;
ref.componentInstance.actions = this.actions;
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);

View file

@ -1,7 +1,8 @@
<ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative">
<div class="position-absolute custom-position-2">
<app-card-actionables [actions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)">
<app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"
[disabled]="bulkMode">
</app-card-actionables>
</div>
@ -72,11 +73,22 @@
<td>
<!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
<app-card-actionables [entity]="library" [inputActions]="actions"></app-card-actionables>
} @else {
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')"
[attr.aria-label]="t('scan-library')">
<i class="fa fa-sync-alt" aria-hidden="true"></i>
</button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)">
<i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')"
[attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)">
<i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')"
[attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
}
</td>
</tr>

View file

@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true;
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
handleKeyUp(_: KeyboardEvent) {
this.isShiftDown = false;
}
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
ngOnInit(): void {
this.getLibraries();
// when a progress event comes in, show it on the UI next to library
// when a progress event comes in, show it on the UI next to the library
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
@ -270,7 +270,8 @@ export class ManageLibraryComponent implements OnInit {
}
}
async handleBulkAction(action: ActionItem<Library>, library : Library | null) {
async handleBulkAction(action: ActionItem<Library>, _: Library) {
//Library is null for bulk actions
this.bulkAction = action.action;
this.cdRef.markForCheck();
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
break;
case (Action.CopySettings):
// Prompt the user for the library then wait for them to manually trigger applyBulkAction
// Prompt the user for the library, then wait for them to manually trigger applyBulkAction
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
@ -298,7 +299,6 @@ export class ManageLibraryComponent implements OnInit {
}
}
async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) {
case(Action.Scan):
@ -321,13 +321,6 @@ export class ManageLibraryComponent implements OnInit {
}
}
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);
}
}
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck();

View file

@ -1,11 +0,0 @@
@if (logs$ | async; as items) {
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
@for (item of scroll.viewPortItems; track item.timestamp) {
<div class="card col-auto mt-2 mb-2">
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
</div>
}
</div>
</virtual-scroller>
}

View file

@ -1,71 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { environment } from 'src/environments/environment';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { AsyncPipe, DatePipe } from '@angular/common';
interface LogMessage {
timestamp: string;
level: 'Information' | 'Debug' | 'Warning' | 'Error';
message: string;
exception: string;
}
@Component({
selector: 'app-manage-logs',
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss'],
standalone: true,
imports: [VirtualScrollerModule, AsyncPipe, DatePipe]
})
export class ManageLogsComponent implements OnInit, OnDestroy {
hubUrl = environment.hubUrl;
private hubConnection!: HubConnection;
logsSource = new BehaviorSubject<LogMessage[]>([]);
public logs$ = this.logsSource.asObservable();
constructor(private accountService: AccountService) { }
ngOnInit(): void {
// TODO: Come back and implement this one day
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'logs', {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
.build();
console.log('Starting log connection');
this.hubConnection
.start()
.catch(err => console.error(err));
this.hubConnection.on('SendLogAsObject', resp => {
const payload = resp.arguments[0] as LogMessage;
const logMessage = {timestamp: payload.timestamp, level: payload.level, message: payload.message, exception: payload.exception};
// NOTE: It might be better to just have a queue to show this
const values = this.logsSource.getValue();
values.push(logMessage);
this.logsSource.next(values);
});
}
});
}
ngOnDestroy(): void {
// unsubscribe from signalr connection
if (this.hubConnection) {
this.hubConnection.stop().catch(err => console.error(err));
console.log('Stopping log connection');
}
}
}

View file

@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
export class UpdateSectionComponent {
@Input({required: true}) items: Array<string> = [];
@Input({required: true}) title: string = '';
// TODO: Implement a read-more-list so that we by default show a configurable number
}

View file

@ -23,7 +23,7 @@
<span class="visually-hidden">{{t('mark-as-read')}}</span>
</button>
}
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" />
</span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>

View file

@ -2,13 +2,14 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef, HostListener,
DestroyRef,
HostListener,
inject,
Input,
OnInit
} from '@angular/core';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { BulkSelectionService } from '../bulk-selection.service';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {BulkSelectionService} from '../bulk-selection.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common";
import {TranslocoModule} from "@jsverse/transloco";
@ -17,18 +18,18 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
import {KEY_CODES} from "../../shared/_services/utility.service";
@Component({
selector: 'app-bulk-operations',
imports: [
AsyncPipe,
CardActionablesComponent,
TranslocoModule,
NgbTooltip,
NgStyle,
DecimalPipe
],
templateUrl: './bulk-operations.component.html',
styleUrls: ['./bulk-operations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-bulk-operations',
imports: [
AsyncPipe,
CardActionablesComponent,
TranslocoModule,
NgbTooltip,
NgStyle,
DecimalPipe
],
templateUrl: './bulk-operations.component.html',
styleUrls: ['./bulk-operations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BulkOperationsComponent implements OnInit {

View file

@ -7,7 +7,7 @@
<h4>
@if (actions.length > 0) {
<span>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span>
}

View file

@ -94,7 +94,7 @@
<span class="card-actions">
@if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
}
</span>
</div>

View file

@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
this.clicked.emit(this.title);
}
preventClick(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {

View file

@ -89,7 +89,7 @@
</span>
<span class="card-actions">
@if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
<app-card-actionables [entity]="chapter" [inputActions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
}
</span>
</div>

View file

@ -3,9 +3,11 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter, HostListener,
EventEmitter,
HostListener,
inject,
Input, OnInit,
Input,
OnInit,
Output
} from '@angular/core';
import {ImageService} from "../../_services/image.service";
@ -14,7 +16,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionItem} from "../../_services/action-factory.service";
import {Chapter} from "../../_models/chapter";
import {Observable} from "rxjs";
import {User} from "../../_models/user";
@ -28,13 +30,10 @@ import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Router, RouterLink} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {filter, map} from "rxjs/operators";
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
import {ReaderService} from "../../_services/reader.service";
import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service";
import {MangaFormat} from "../../_models/manga-format";
@Component({
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly downloadService = inject(DownloadService);
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService);
private readonly accountService = inject(AccountService);
private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly router = inject(Router);
private readonly readerService = inject(ReaderService);
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) libraryId: number = 0;
@Input({required: true}) seriesId: number = 0;
@Input({required: true}) chapter!: Chapter;
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
}
ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user;
});
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
this.cdRef.detectChanges();
}
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.chapter);
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
this.downloadService.download('chapter', this.chapter);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([this.chapter.id], device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.chapter);
}
}
handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event);
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
event.stopPropagation();
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
}
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
}

View file

@ -32,7 +32,7 @@
</span>
@if (actions && actions.length > 0) {
<span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
<app-card-actionables [entity]="entity" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
</span>
}
</div>

View file

@ -1,20 +1,20 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, ContentChild,
DestroyRef, EventEmitter,
Component,
ContentChild,
DestroyRef,
EventEmitter,
HostListener,
inject,
Input, Output, TemplateRef
Input,
Output,
TemplateRef
} from '@angular/core';
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionItem} from "../../_services/action-factory.service";
import {ImageService} from "../../_services/image.service";
import {BulkSelectionService} from "../bulk-selection.service";
import {LibraryService} from "../../_services/library.service";
import {DownloadService} from "../../shared/_services/download.service";
import {UtilityService} from "../../shared/_services/utility.service";
import {MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@ -139,11 +139,6 @@ export class PersonCardComponent {
this.clicked.emit(this.title);
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
handleSelection(event?: any) {
if (event) {

View file

@ -74,7 +74,7 @@
@if (actions && actions.length > 0) {
<span class="card-actions">
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
<app-card-actionables [entity]="series" [inputActions]="actions" [labelBy]="series.name"></app-card-actionables>
</span>
}
</div>

View file

@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
import {DecimalPipe} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
import {Device} from "../../_models/device/device";
import {translate, TranslocoDirective} from "@jsverse/transloco";
@ -30,7 +29,6 @@ import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component";
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
@ -39,7 +37,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators";
import {AccountService} from "../../_services/account.service";
import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
*/
prevOffset: number = 0;
selectionInProgress: boolean = false;
private user: User | undefined;
@HostListener('touchmove', ['$event'])
onTouchMove(event: TouchEvent) {
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
ngOnChanges(changes: any) {
if (this.series) {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user;
});
// this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
// this.user = user;
// });
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series);
}));
this.actions = [...this.actionFactoryService.getSeriesActions((action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series))];
this.actions = [...this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))];
if (this.isOnDeck) {
const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
action: Action.RemoveFromOnDeck,
title: 'remove-from-on-deck',
description: '',
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series),
callback: this.handleSeriesActionCallback.bind(this),
class: 'danger',
requiresAdmin: false,
requiredRoles: [],
shouldRender: (_, _2, _3) => true,
children: [],
});
this.actions[othersIndex] = othersAction;

View file

@ -81,7 +81,7 @@
@if (actions && actions.length > 0) {
<span class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables>
<app-card-actionables [entity]="volume" [inputActions]="actions" [labelBy]="volume.name"></app-card-actionables>
</span>
}
</div>

View file

@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionItem} from "../../_services/action-factory.service";
import {ActionItem} from "../../_services/action-factory.service";
import {ReaderService} from "../../_services/reader.service";
import {Observable} from "rxjs";
import {User} from "../../_models/user";
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
import {Volume} from "../../_models/volume";
import {UtilityService} from "../../shared/_services/utility.service";
import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service";
import {FormsModule} from "@angular/forms";
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
}
ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user;
});
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
this.cdRef.detectChanges();
}
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
// TODO: See if we can handle send to for volumes
//this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.volume);
}
performAction(action: ActionItem<Volume>) {
if (action.action == Action.Download) {
this.downloadService.download('volume', this.volume);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(this.volume.chapters.map(c => c.id), device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.volume);
}
}
handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event);

View file

@ -4,7 +4,7 @@
<div class="carousel-container mb-3">
<div>
@if (actionables.length > 0) {
<app-card-actionables [actions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
<app-card-actionables [inputActions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
}
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
@if (titleLink !== '') {

View file

@ -74,7 +74,7 @@
<div class="col-auto ms-2k">
<div class="card-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
<app-card-actionables [entity]="chapter" [inputActions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
</div>
</div>

View file

@ -83,7 +83,7 @@ enum TabID {
}
@Component({
selector: 'app-chapter-detail',
selector: 'app-chapter-detail',
imports: [
AsyncPipe,
CardActionablesComponent,
@ -116,9 +116,9 @@ enum TabID {
ReviewsComponent,
ExternalRatingComponent
],
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChapterDetailComponent implements OnInit {
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
this.location.replaceState(newUrl)
}
openPerson(field: FilterField, value: number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
downloadChapter() {
if (this.downloadInProgress) return;
this.downloadService.download('chapter', this.chapter!, (d) => {
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
this.cdRef.markForCheck();
}
performAction(action: ActionItem<Chapter>) {
if (typeof action.callback === 'function') {
action.callback(action, this.chapter!);
}
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {

View file

@ -6,11 +6,9 @@
<ng-container title>
@if (collectionTag) {
<h4>
{{collectionTag.title}}
@if(collectionTag.promoted) {
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
}
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
<app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
<span class="ms-2">{{collectionTag.title}}</span>
<app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
</h4>
}
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>

View file

@ -61,6 +61,7 @@ import {
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
@Component({
selector: 'app-collection-detail',
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe]
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
})
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -304,12 +305,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.collectionTag);
}
}
openEditCollectionTagModal(collectionTag: UserCollection) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
modalRef.componentInstance.tag = this.collectionTag;
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
openSyncDetailDrawer() {
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.collection = this.collectionTag;
ref.componentInstance.series = this.series;

View file

@ -3,7 +3,7 @@
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title>
<span>{{libraryName}}</span>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<app-card-actionables [inputActions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
</h4>
@if (active.fragment === '') {
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
@ -31,7 +31,6 @@
</ng-template>
<ng-template #noData>
<!-- TODO: Come back and figure this out -->
{{t('common.no-data')}}
</ng-template>
</app-card-detail-layout>

View file

@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, undefined);

View file

@ -15,6 +15,10 @@
} @else {
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
}
} @else {
<div class="input-hint">
Ctrl+K
</div>
}
</div>
</div>

View file

@ -9,6 +9,17 @@
right: 5px;
}
.input-hint {
font-size: 0.8rem;
margin-top: 3px;
margin-bottom: 3px;
margin-right: 9px;
border: 1px solid var(--input-hint-border-color, lightgrey);
color: var(--input-hint-text-color);
border-radius: 4px;
padding: 2px;
}
.typeahead-input {
border: 1px solid transparent;

View file

@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
hasFocus: boolean = false;
typeaheadForm: FormGroup = new FormGroup({});
typeaheadForm: FormGroup = new FormGroup({
typeahead: new FormControl('', []),
});
includeChapterAndFiles: boolean = false;
prevSearchTerm: string = '';
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
this.close();
}
@HostListener('window:keydown', ['$event'])
@HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (!this.hasFocus) { return; }
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
switch(event.key) {
case KEY_CODES.ESC_KEY:
if (!this.hasFocus) { return; }
this.close();
event.stopPropagation();
break;
case KEY_CODES.K:
if (isCtrlOrMeta) {
if (this.inputElem.nativeElement) {
event.preventDefault();
event.stopPropagation();
this.inputElem.nativeElement.focus();
this.inputElem.nativeElement.click();
}
}
break;
default:
break;
}
}
ngOnInit(): void {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.cdRef.markForCheck();
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(

View file

@ -5,7 +5,7 @@
<app-side-nav-companion-bar>
<ng-container title>
<h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<app-card-actionables [entity]="person" [inputActions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{person.name}}</span>
@if (person.aniListId) {

View file

@ -19,7 +19,6 @@ import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
@ -89,7 +88,7 @@ export class PersonDetailComponent implements OnInit {
private readonly toastr = inject(ToastrService);
private readonly messageHubService = inject(MessageHubService)
protected readonly TagBadgeCursor = TagBadgeCursor;
protected readonly FilterField = FilterField;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -278,11 +277,4 @@ export class PersonDetailComponent implements OnInit {
});
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.person);
}
}
protected readonly FilterField = FilterField;
}

View file

@ -10,12 +10,10 @@
<div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
<h4 class="title mb-2">
<span>{{readingList.title}}
@if (readingList.promoted) {
(<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>)
}
@if( isLoading) {
<span>
<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>
<span class="ms-2">{{readingList.title}}</span>
@if(isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span>
</div>
@ -87,7 +85,7 @@
<div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
<app-card-actionables [entity]="readingList" [inputActions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
</div>
</div>

View file

@ -273,11 +273,6 @@ export class ReadingListDetailComponent implements OnInit {
});
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.readingList);
}
}
readChapter(item: ReadingListItem) {
if (!this.readingList) return;
@ -387,12 +382,6 @@ export class ReadingListDetailComponent implements OnInit {
{queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}});
}
updateAccessibilityMode() {
this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck();
}
toggleReorder() {
this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value);
this.cdRef.markForCheck();

View file

@ -3,7 +3,7 @@
<app-side-nav-companion-bar>
<h4 title>
<span>{{t('title')}}</span>
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
<app-card-actionables [inputActions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
</h4>
@if (pagination) {
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h5>

View file

@ -96,7 +96,7 @@
<div class="col-auto ms-2">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
<app-card-actionables [entity]="series" [inputActions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
</div>
</div>

View file

@ -61,11 +61,6 @@ import {ReaderService} from 'src/app/_services/reader.service';
import {ReadingListService} from 'src/app/_services/reading-list.service';
import {ScrollService} from 'src/app/_services/scroll.service';
import {SeriesService} from 'src/app/_services/series.service';
import {
ReviewModalCloseAction,
ReviewModalCloseEvent,
ReviewModalComponent
} from '../../../_single-module/review-modal/review-modal.component';
import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {UserReview} from "../../../_single-module/review-card/user-review";
@ -73,8 +68,6 @@ import {ExternalSeriesCardComponent} from '../../../cards/external-series-card/e
import {SeriesCardComponent} from '../../../cards/series-card/series-card.component';
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
import {ReviewCardComponent} from '../../../_single-module/review-card/review-card.component';
import {CarouselReelComponent} from '../../../carousel/_components/carousel-reel/carousel-reel.component';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {PublicationStatus} from "../../../_models/metadata/publication-status";
@ -1138,13 +1131,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
});
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.series);
}
}
downloadSeries() {
this.downloadService.download('series', this.series, (d) => {
this.downloadInProgress = !!d;

View file

@ -1,12 +1,12 @@
import { HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library/library';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PaginatedResult } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume';
import {translate, TranslocoService} from "@jsverse/transloco";
import {HttpParams} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Chapter} from 'src/app/_models/chapter';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {PaginatedResult} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {Volume} from 'src/app/_models/volume';
import {translate} from "@jsverse/transloco";
import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
export enum KEY_CODES {
@ -21,6 +21,7 @@ export enum KEY_CODES {
B = 'b',
F = 'f',
H = 'h',
K = 'k',
BACKSPACE = 'Backspace',
DELETE = 'Delete',
SHIFT = 'Shift'
@ -41,6 +42,9 @@ export class UtilityService {
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true}));
// TODO: I need an isPhone/Tablet so that I can easily trigger different views
mangaFormatKeys: string[] = [];

View file

@ -8,8 +8,7 @@
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/">
<ng-container actions>
<app-card-actionables [actions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v"
(actionHandler)="performHomeAction($event)" />
<app-card-actionables [inputActions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v" (actionHandler)="performHomeAction($event)" />
</ng-container>
</app-side-nav-item>
@ -44,8 +43,7 @@
[imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name"
[comparisonMethod]="'startsWith'">
<ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
<app-card-actionables [entity]="navStream.library" [inputActions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"></app-card-actionables>
</ng-container>
</app-side-nav-item>
}

View file

@ -155,24 +155,25 @@ export class SideNavComponent implements OnInit {
}
async handleAction(action: ActionItem<Library>, library: Library) {
const lib = library;
switch (action.action) {
case(Action.Scan):
await this.actionService.scanLibrary(library);
await this.actionService.scanLibrary(lib);
break;
case(Action.RefreshMetadata):
await this.actionService.refreshLibraryMetadata(library);
await this.actionService.refreshLibraryMetadata(lib);
break;
case(Action.GenerateColorScape):
await this.actionService.refreshLibraryMetadata(library, undefined, false);
await this.actionService.refreshLibraryMetadata(lib, undefined, false);
break;
case (Action.AnalyzeFiles):
await this.actionService.analyzeFiles(library);
await this.actionService.analyzeFiles(lib);
break;
case (Action.Delete):
await this.actionService.deleteLibrary(library);
await this.actionService.deleteLibrary(lib);
break;
case (Action.Edit):
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
this.actionService.editLibrary(lib, () => window.scrollTo(0, 0));
break;
default:
break;
@ -191,6 +192,7 @@ export class SideNavComponent implements OnInit {
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
console.log('library: ', library)
action.callback(action, library);
}
}

View file

@ -257,12 +257,12 @@ export class LibrarySettingsModalComponent implements OnInit {
// TODO: Refactor into FormArray
for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), []));
this.libraryForm.addControl(fileTypeGroup + '', new FormControl((this.library.libraryFileTypes || []).includes(fileTypeGroup), []));
}
// TODO: Refactor into FormArray
for(let glob of this.library.excludePatterns) {
this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, []));
this.libraryForm.addControl('excludeGlob-', new FormControl(glob, []));
}
this.excludePatterns = this.library.excludePatterns;

View file

@ -77,7 +77,7 @@
<div class="col-auto ms-2">
<div class="card-actions mt-2" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
<app-card-actionables [entity]="volume" [inputActions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
</div>
</div>

View file

@ -80,6 +80,7 @@ import {UserReview} from "../_single-module/review-card/user-review";
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {ChapterService} from "../_services/chapter.service";
import {User} from "../_models/user";
enum TabID {
@ -187,6 +188,7 @@ export class VolumeDetailComponent implements OnInit {
protected readonly TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
protected readonly encodeURIComponent = encodeURIComponent;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -211,7 +213,7 @@ export class VolumeDetailComponent implements OnInit {
mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false;
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this));
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this), this.shouldRenderVolumeAction.bind(this));
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
@ -570,16 +572,6 @@ export class VolumeDetailComponent implements OnInit {
this.location.replaceState(newUrl)
}
openPerson(field: FilterField, value: number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
performAction(action: ActionItem<Volume>) {
if (typeof action.callback === 'function') {
action.callback(action, this.volume!);
}
}
async handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
@ -610,6 +602,17 @@ export class VolumeDetailComponent implements OnInit {
}
}
shouldRenderVolumeAction(action: ActionItem<Volume>, entity: Volume, user: User) {
switch (action.action) {
case(Action.MarkAsRead):
return entity.pagesRead < entity.pages;
case(Action.MarkAsUnread):
return entity.pagesRead !== 0;
default:
return true;
}
}
async handleVolumeAction(action: ActionItem<Volume>) {
switch (action.action) {
case Action.Delete:
@ -687,6 +690,4 @@ export class VolumeDetailComponent implements OnInit {
this.currentlyReadingChapter = undefined;
}
}
protected readonly encodeURIComponent = encodeURIComponent;
}

View file

@ -439,4 +439,8 @@
/** Series Detail **/
--detail-subtitle-color: lightgrey;
/** Search **/
--input-hint-border-color: #aeaeae;
--input-hint-text-color: lightgrey;
}