Ported my new architecture to Kavita for actionables. The new architecture allows for Actions to be rendered only when needed (aka mark as unread with an unread series makes no sense) and reduces a lot of boilerplate (performAction).

This commit is contained in:
Joseph Milazzo 2025-05-10 09:39:56 -05:00
parent 82d5d98c03
commit d77b45862b
30 changed files with 444 additions and 228 deletions

View file

@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list'; import {ReadingList} from '../_models/reading-list';
import {Series} from '../_models/series'; import {Series} from '../_models/series';
import {Volume} from '../_models/volume'; import {Volume} from '../_models/volume';
import {AccountService} from './account.service'; import {AccountService, Role} from './account.service';
import {DeviceService} from './device.service'; import {DeviceService} from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person"; import {Person} from "../_models/metadata/person";
import {User} from '../_models/user';
export enum Action { export enum Action {
Submenu = -1, Submenu = -1,
@ -106,7 +107,7 @@ export enum Action {
Promote = 24, Promote = 24,
UnPromote = 25, UnPromote = 25,
/** /**
* Invoke a refresh covers as false to generate colorscapes * Invoke refresh covers as false to generate colorscapes
*/ */
GenerateColorScape = 26, GenerateColorScape = 26,
/** /**
@ -126,14 +127,21 @@ export enum Action {
/** /**
* Callback for an action * Callback for an action
*/ */
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void; export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean; export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
export interface ActionItem<T> { export interface ActionItem<T> {
title: string; title: string;
description: string; description: string;
action: Action; action: Action;
callback: ActionCallback<T>; 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; requiresAdmin: boolean;
children: Array<ActionItem<T>>; children: Array<ActionItem<T>>;
/** /**
@ -149,94 +157,97 @@ 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 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}; _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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ActionFactoryService { export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = []; private libraryActions: Array<ActionItem<Library>> = [];
private seriesActions: Array<ActionItem<Series>> = [];
seriesActions: Array<ActionItem<Series>> = []; private volumeActions: Array<ActionItem<Volume>> = [];
private chapterActions: Array<ActionItem<Chapter>> = [];
volumeActions: Array<ActionItem<Volume>> = []; private collectionTagActions: Array<ActionItem<UserCollection>> = [];
private readingListActions: Array<ActionItem<ReadingList>> = [];
chapterActions: Array<ActionItem<Chapter>> = []; private bookmarkActions: Array<ActionItem<Series>> = [];
collectionTagActions: Array<ActionItem<UserCollection>> = [];
readingListActions: Array<ActionItem<ReadingList>> = [];
bookmarkActions: Array<ActionItem<Series>> = [];
private personActions: Array<ActionItem<Person>> = []; private personActions: Array<ActionItem<Person>> = [];
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = []; private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = []; private sideNavHomeActions: Array<ActionItem<void>> = [];
sideNavHomeActions: Array<ActionItem<void>> = [];
isAdmin = false;
constructor(private accountService: AccountService, private deviceService: DeviceService) { constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => { this.accountService.currentUser$.subscribe((_) => {
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._resetActions(); this._resetActions();
}); });
} }
getLibraryActions(callback: ActionCallback<Library>) { getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
return this.applyCallbackToList(this.libraryActions, callback); return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
} }
getSeriesActions(callback: ActionCallback<Series>) { getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
return this.applyCallbackToList(this.seriesActions, callback); return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
} }
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) { getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavStreamActions, callback); return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
} }
getSmartFilterActions(callback: ActionCallback<SmartFilter>) { getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
return this.applyCallbackToList(this.smartFilterActions, callback); return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
} }
getVolumeActions(callback: ActionCallback<Volume>) { getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
return this.applyCallbackToList(this.volumeActions, callback); return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
} }
getChapterActions(callback: ActionCallback<Chapter>) { getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
return this.applyCallbackToList(this.chapterActions, callback); return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
} }
getCollectionTagActions(callback: ActionCallback<UserCollection>) { getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
return this.applyCallbackToList(this.collectionTagActions, callback); return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
} }
getReadingListActions(callback: ActionCallback<ReadingList>) { getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
return this.applyCallbackToList(this.readingListActions, callback); return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
} }
getBookmarkActions(callback: ActionCallback<Series>) { getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
return this.applyCallbackToList(this.bookmarkActions, callback); return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
} }
getPersonActions(callback: ActionCallback<Person>) { getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
return this.applyCallbackToList(this.personActions, callback); return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
} }
getSideNavHomeActions(callback: ActionCallback<void>) { getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavHomeActions, callback); 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.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) { filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
@ -279,7 +290,7 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action)); 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 // 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 => { const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
@ -293,11 +304,13 @@ export class ActionFactoryService {
dynamicList: undefined, dynamicList: undefined,
action: Action.CopySettings, action: Action.CopySettings,
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: shouldRenderFunc,
children: [], children: [],
requiredRoles: [Role.Admin],
requiresAdmin: true, requiresAdmin: true,
title: 'copy-settings' 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>> { flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
@ -323,7 +336,9 @@ export class ActionFactoryService {
title: 'scan-library', title: 'scan-library',
description: 'scan-library-tooltip', description: 'scan-library-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -331,14 +346,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -346,7 +365,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -354,7 +375,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -362,7 +385,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
], ],
@ -372,7 +397,9 @@ export class ActionFactoryService {
title: 'settings', title: 'settings',
description: 'settings-tooltip', description: 'settings-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -383,7 +410,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -391,7 +420,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -400,7 +431,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -408,7 +441,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -419,7 +454,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -427,7 +464,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -435,7 +474,9 @@ export class ActionFactoryService {
title: 'scan-series', title: 'scan-series',
description: 'scan-series-tooltip', description: 'scan-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -443,14 +484,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToWantToReadList, action: Action.AddToWantToReadList,
title: 'add-to-want-to-read', title: 'add-to-want-to-read',
description: 'add-to-want-to-read-tooltip', description: 'add-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -458,7 +503,9 @@ export class ActionFactoryService {
title: 'remove-from-want-to-read', title: 'remove-from-want-to-read',
description: 'remove-to-want-to-read-tooltip', description: 'remove-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -466,7 +513,9 @@ export class ActionFactoryService {
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -474,26 +523,11 @@ export class ActionFactoryService {
title: 'add-to-collection', title: 'add-to-collection',
description: 'add-to-collection-tooltip', description: 'add-to-collection-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], 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 +535,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -521,14 +559,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -536,7 +578,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -544,7 +588,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -552,7 +598,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -563,7 +611,9 @@ export class ActionFactoryService {
title: 'match', title: 'match',
description: 'match-tooltip', description: 'match-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -571,7 +621,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
{ {
@ -579,7 +631,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -590,7 +644,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -598,7 +654,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -606,7 +664,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -614,14 +674,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '=', description: '=',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -631,14 +695,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -651,14 +719,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -666,7 +738,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
] ]
@ -676,7 +750,9 @@ export class ActionFactoryService {
title: 'details', title: 'details',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -687,7 +763,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -695,7 +773,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -703,7 +783,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -711,14 +793,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -728,14 +814,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -749,14 +839,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -764,7 +858,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
] ]
@ -774,7 +870,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -785,7 +883,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -793,7 +893,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -802,7 +904,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -810,7 +914,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -821,7 +927,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-person-tooltip', description: 'edit-person-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -829,7 +937,9 @@ export class ActionFactoryService {
title: 'merge', title: 'merge',
description: 'merge-person-tooltip', description: 'merge-person-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
} }
]; ];
@ -840,7 +950,9 @@ export class ActionFactoryService {
title: 'view-series', title: 'view-series',
description: 'view-series-tooltip', description: 'view-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -848,7 +960,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -856,8 +970,10 @@ export class ActionFactoryService {
title: 'clear', title: 'clear',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -868,7 +984,9 @@ export class ActionFactoryService {
title: 'mark-visible', title: 'mark-visible',
description: 'mark-visible-tooltip', description: 'mark-visible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -876,7 +994,9 @@ export class ActionFactoryService {
title: 'mark-invisible', title: 'mark-invisible',
description: 'mark-invisible-tooltip', description: 'mark-invisible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -887,7 +1007,9 @@ export class ActionFactoryService {
title: 'rename', title: 'rename',
description: 'rename-tooltip', description: 'rename-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -895,7 +1017,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -906,7 +1030,9 @@ export class ActionFactoryService {
title: 'reorder', title: 'reorder',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -914,21 +1040,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.callback = callback;
action.shouldRender = shouldRenderFunc;
if (action.children === null || action.children?.length === 0) return; if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => { 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) => { const actions = list.map((a) => {
return { ...a }; return { ...a };
}); });
actions.forEach((action) => this.applyCallback(action, callback)); actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
return actions; return actions;
} }

View file

@ -1,7 +1,9 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <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> <button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
@ -12,8 +14,6 @@
} }
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (action of currentItems; track action.title) { @for (action of currentItems; track action.title) {
@if (willRenderAction(action)) { @if (willRenderAction(action)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100" <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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgClass} from "@angular/common";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; 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 {AccountService} from "../../_services/account.service";
import {tap} from "rxjs"; import {tap} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
protected readonly destroyRef = inject(DestroyRef); protected readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = []; @Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>) => boolean; @Input() willRenderAction!: (action: ActionItem<any>) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean; @Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;

View file

@ -25,19 +25,25 @@
@for(dynamicItem of dList; track dynamicItem.title) { @for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button> <button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
} }
} @else if (willRenderAction(action)) { } @else if (willRenderAction(action, this.currentUser!)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button> <button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
} }
} @else { } @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) { @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
<!-- Submenu items --> <!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left" <div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (click)="openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseenter)="openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)"> (mouseover)="preventEvent($event)"
@if (willRenderAction(action)) { class="submenu-wrapper">
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
<!-- 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}}"> <div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container> <ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div> </div>

View file

@ -2,6 +2,22 @@
content: none !important; 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 { .submenu-toggle {
display: block; display: block;
width: 100%; width: 100%;
@ -30,9 +46,3 @@
.btn { .btn {
padding: 5px; 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnChanges,
OnDestroy,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {AccountService} from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {User} from "../../_models/user";
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet], imports: [
NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
],
templateUrl: './card-actionables.component.html', templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'], styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CardActionablesComponent implements OnInit { export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
@Input() iconClass = 'fa-ellipsis-v'; @Input() iconClass = 'fa-ellipsis-v';
@Input() btnClass = ''; @Input() btnClass = '';
@Input() actions: ActionItem<any>[] = []; @Input() inputActions: ActionItem<any>[] = [];
@Input() labelBy = 'card'; @Input() labelBy = 'card';
/** /**
* Text to display as if actionable was a button * Text to display as if actionable was a button
*/ */
@Input() label = ''; @Input() label = '';
@Input() disabled: boolean = false; @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>>(); @Output() actionHandler = new EventEmitter<ActionItem<any>>();
isAdmin: boolean = false; actions: ActionItem<ActionableEntity>[] = [];
canDownload: boolean = false; currentUser: User | undefined = undefined;
canPromote: boolean = false;
submenu: {[key: string]: NgbDropdown} = {}; submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
if (!user) return; if (!user) return;
this.isAdmin = this.accountService.hasAdminRole(user); this.currentUser = user;
this.canDownload = this.accountService.hasDownloadRole(user); this.actions = this.inputActions.filter(a => this.willRenderAction(a, 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(); this.cdRef.markForCheck();
}); });
} }
ngOnChanges() {
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.cancelCloseSubmenus();
}
preventEvent(event: any) { preventEvent(event: any) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
} }
performAction(event: any, action: ActionItem<any>) { performAction(event: any, action: ActionItem<ActionableEntity>) {
this.preventEvent(event); this.preventEvent(event);
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
if (this.entity === null) {
this.actionHandler.emit(action); this.actionHandler.emit(action);
} else {
action.callback(action, this.entity);
}
} }
} }
willRenderAction(action: ActionItem<any>) { /**
return (action.requiresAdmin && this.isAdmin) * The user has required roles (or no roles defined) and action shouldRender returns true
|| (action.action === Action.Download && (this.canDownload || this.isAdmin)) * @param action
|| (!action.requiresAdmin && action.action !== Action.Download) * @param user
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin)) */
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin)) 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>) { shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
} }
closeAllSubmenus() { closeAllSubmenus() {
// 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 => { Object.keys(this.submenu).forEach(key => {
this.submenu[key].close(); this.submenu[key].close();
delete this.submenu[key]; 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; action._extra = dynamicItem;
this.performAction(event, action); this.performAction(event, action);
} }
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
this.preventEvent(event); this.preventEvent(event);
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true}); const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
ref.componentInstance.entity = this.entity;
ref.componentInstance.actions = this.actions; ref.componentInstance.actions = this.actions;
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this); ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this); ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);

View file

@ -1,7 +1,8 @@
<ng-container *transloco="let t; read: 'manage-library'"> <ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative"> <div class="position-relative">
<div class="position-absolute custom-position-2"> <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> </app-card-actionables>
</div> </div>
@ -72,11 +73,22 @@
<td> <td>
<!-- On Mobile we want to use ... for each row --> <!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) { @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 { } @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-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')"
<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> [attr.aria-label]="t('scan-library')">
<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> <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> </td>
</tr> </tr>

View file

@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
lastSelectedIndex: number | null = null; lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event']) @HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) { handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true; this.isShiftDown = true;
} }
@HostListener('document:keyup.shift', ['$event']) @HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) { handleKeyUp(_: KeyboardEvent) {
this.isShiftDown = false; this.isShiftDown = false;
} }
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.getLibraries(); 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), this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress), filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) => 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.bulkAction = action.action;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
break; break;
case (Action.CopySettings): 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'}); const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries; ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => { 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) { async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) { switch (action.action) {
case(Action.Scan): 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() { setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries); this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View file

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

View file

@ -2,7 +2,8 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, HostListener, DestroyRef,
HostListener,
inject, inject,
Input, Input,
OnInit OnInit
@ -84,6 +85,8 @@ export class BulkOperationsComponent implements OnInit {
this.actionCallback(action, null); this.actionCallback(action, null);
} }
executeAction(action: Action) { executeAction(action: Action) {
const foundActions = this.actions.filter(act => act.action === action); const foundActions = this.actions.filter(act => act.action === action);
if (foundActions.length > 0) { if (foundActions.length > 0) {

View file

@ -7,7 +7,7 @@
<h4> <h4>
@if (actions.length > 0) { @if (actions.length > 0) {
<span> <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> </span>
} }

View file

@ -94,7 +94,7 @@
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @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> </span>
</div> </div>

View file

@ -89,7 +89,7 @@
</span> </span>
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @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> </span>
</div> </div>

View file

@ -32,7 +32,7 @@
</span> </span>
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions float-end"> <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> </span>
} }
</div> </div>

View file

@ -74,7 +74,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <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> </span>
} }
</div> </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 {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
import {RelationKind} from 'src/app/_models/series-detail/relation-kind'; import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {RelationshipPipe} from "../../_pipes/relationship.pipe";
import {Device} from "../../_models/device/device"; import {Device} from "../../_models/device/device";
import {translate, TranslocoDirective} from "@jsverse/transloco"; 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 {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component"; import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service"; import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
@ -212,6 +210,8 @@ export class SeriesCardComponent implements OnInit, OnChanges {
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series), callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series),
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
shouldRender: (_, _2, _3) => true,
children: [], children: [],
}); });
this.actions[othersIndex] = othersAction; this.actions[othersIndex] = othersAction;

View file

@ -81,7 +81,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <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> </span>
} }
</div> </div>

View file

@ -4,7 +4,7 @@
<div class="carousel-container mb-3"> <div class="carousel-container mb-3">
<div> <div>
@if (actionables.length > 0) { @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}"> <h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
@if (titleLink !== '') { @if (titleLink !== '') {

View file

@ -74,7 +74,7 @@
<div class="col-auto ms-2k"> <div class="col-auto ms-2k">
<div class="card-actions" [ngbTooltip]="t('more-alt')"> <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>
</div> </div>

View file

@ -10,7 +10,7 @@
@if(collectionTag.promoted) { @if(collectionTag.promoted) {
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span> <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-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
</h4> </h4>
} }
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>

View file

@ -3,7 +3,7 @@
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title> <h4 title>
<span>{{libraryName}}</span> <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> </h4>
@if (active.fragment === '') { @if (active.fragment === '') {
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5> <h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>

View file

@ -5,7 +5,7 @@
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<ng-container title> <ng-container title>
<h2 class="title text-break"> <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> <span>{{person.name}}</span>
@if (person.aniListId) { @if (person.aniListId) {

View file

@ -87,7 +87,7 @@
<div class="col-auto ms-2 d-none d-md-block"> <div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')"> <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>
</div> </div>

View file

@ -3,7 +3,7 @@
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<h4 title> <h4 title>
<span>{{t('title')}}</span> <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> </h4>
@if (pagination) { @if (pagination) {
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h5> <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="col-auto ms-2">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')"> <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>
</div> </div>

View file

@ -42,6 +42,9 @@ export class UtilityService {
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1); public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); 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[] = []; mangaFormatKeys: string[] = [];

View file

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

View file

@ -77,7 +77,7 @@
<div class="col-auto ms-2"> <div class="col-auto ms-2">
<div class="card-actions mt-2" [ngbTooltip]="t('more-alt')"> <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>
</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 {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component"; import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {ChapterService} from "../_services/chapter.service"; import {ChapterService} from "../_services/chapter.service";
import {User} from "../_models/user";
enum TabID { enum TabID {
@ -211,7 +212,7 @@ export class VolumeDetailComponent implements OnInit {
mobileSeriesImgBackground: string | undefined; mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false; 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)); chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => { bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
@ -610,6 +611,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>) { async handleVolumeAction(action: ActionItem<Volume>) {
switch (action.action) { switch (action.action) {
case Action.Delete: case Action.Delete: