Merge remote-tracking branch 'upstream/develop' into feature/qol-bulk-operations-style-changes

This commit is contained in:
Christopher 2025-05-11 22:28:50 -06:00
commit 707fb2cc72
374 changed files with 9175 additions and 3420 deletions

View file

@ -13,7 +13,7 @@
}
.subtitle {
color: lightgrey;
color: var(--detail-subtitle-color);
font-weight: bold;
font-size: 0.8rem;
}

View file

@ -6,6 +6,9 @@ export enum LibraryType {
Book = 2,
Images = 3,
LightNovel = 4,
/**
* Comic (Legacy)
*/
ComicVine = 5
}

View file

@ -22,6 +22,7 @@ export interface Person extends IHasCover {
id: number;
name: string;
description: string;
aliases: Array<string>;
coverImage?: string;
coverImageLocked: boolean;
malId?: number;

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,
/**
@ -116,20 +117,31 @@ export enum Action {
/**
* Match an entity with an upstream system
*/
Match = 28
Match = 28,
/**
* Merge two (or more?) entities
*/
Merge = 29,
}
/**
* 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>>;
/**
@ -145,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) {
@ -275,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 => {
@ -289,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>> {
@ -319,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: [],
},
{
@ -327,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: [],
},
{
@ -342,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: [],
},
{
@ -350,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: [],
},
{
@ -358,7 +386,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
],
@ -368,7 +398,9 @@ export class ActionFactoryService {
title: 'settings',
description: 'settings-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
];
@ -379,7 +411,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -387,7 +421,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
class: 'danger',
children: [],
},
@ -396,7 +432,9 @@ export class ActionFactoryService {
title: 'promote',
description: 'promote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -404,7 +442,9 @@ export class ActionFactoryService {
title: 'unpromote',
description: 'unpromote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -415,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: [],
},
{
@ -423,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: [],
},
{
@ -431,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: [],
},
{
@ -439,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: [],
},
{
@ -454,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: [],
},
{
@ -462,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: [],
},
{
@ -470,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: [],
// },
],
},
{
@ -497,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())),
@ -517,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: [],
},
{
@ -532,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: [],
},
{
@ -540,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: [],
},
{
@ -548,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: [],
},
@ -559,7 +612,9 @@ export class ActionFactoryService {
title: 'match',
description: 'match-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
@ -567,7 +622,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [Role.Download],
children: [],
},
{
@ -575,7 +632,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
];
@ -586,7 +645,9 @@ export class ActionFactoryService {
title: 'read-incognito',
description: 'read-incognito-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -594,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: [],
},
{
@ -602,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: [],
},
{
@ -610,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: [],
}
]
@ -627,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())),
@ -647,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: [],
},
{
@ -662,7 +739,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
]
@ -672,7 +751,9 @@ export class ActionFactoryService {
title: 'details',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -683,7 +764,9 @@ export class ActionFactoryService {
title: 'read-incognito',
description: 'read-incognito-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -691,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: [],
},
{
@ -699,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: [],
},
{
@ -707,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: [],
}
]
@ -724,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())),
@ -745,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: [],
},
{
@ -760,7 +859,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [Role.Download],
children: [],
},
]
@ -770,7 +871,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -781,7 +884,9 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -789,7 +894,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
class: 'danger',
children: [],
},
@ -798,7 +905,9 @@ export class ActionFactoryService {
title: 'promote',
description: 'promote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -806,7 +915,9 @@ export class ActionFactoryService {
title: 'unpromote',
description: 'unpromote-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -817,7 +928,19 @@ export class ActionFactoryService {
title: 'edit',
description: 'edit-person-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
action: Action.Merge,
title: 'merge',
description: 'merge-person-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
}
];
@ -828,7 +951,9 @@ export class ActionFactoryService {
title: 'view-series',
description: 'view-series-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -836,7 +961,9 @@ export class ActionFactoryService {
title: 'download',
description: 'download-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -844,8 +971,10 @@ export class ActionFactoryService {
title: 'clear',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
class: 'danger',
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -856,7 +985,9 @@ export class ActionFactoryService {
title: 'mark-visible',
description: 'mark-visible-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -864,7 +995,9 @@ export class ActionFactoryService {
title: 'mark-invisible',
description: 'mark-invisible-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -875,7 +1008,9 @@ export class ActionFactoryService {
title: 'rename',
description: 'rename-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
@ -883,7 +1018,9 @@ export class ActionFactoryService {
title: 'delete',
description: 'delete-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
];
@ -894,7 +1031,9 @@ export class ActionFactoryService {
title: 'reorder',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
}
]
@ -902,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) {
@ -536,7 +535,7 @@ export class ActionService {
addMultipleSeriesToWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => {
this.toastr.success('Series added to Want to Read list');
this.toastr.success(translate('toasts.series-added-want-to-read'));
if (callback) {
callback();
}

View file

@ -109,7 +109,11 @@ export enum EVENTS {
/**
* A Progress event when a smart collection is synchronizing
*/
SmartCollectionSync = 'SmartCollectionSync'
SmartCollectionSync = 'SmartCollectionSync',
/**
* A Person merged has been merged into another
*/
PersonMerged = 'PersonMerged',
}
export interface Message<T> {
@ -336,6 +340,13 @@ export class MessageHubService {
payload: resp.body
});
});
this.hubConnection.on(EVENTS.PersonMerged, resp => {
this.messagesSource.next({
event: EVENTS.PersonMerged,
payload: resp.body
});
})
}
stopHubConnection() {

View file

@ -1,14 +1,12 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from "@angular/common/http";
import {Injectable} from '@angular/core';
import {HttpClient, HttpParams} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {Person, PersonRole} from "../_models/metadata/person";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {PaginatedResult} from "../_models/pagination";
import {Series} from "../_models/series";
import {map} from "rxjs/operators";
import {UtilityService} from "../shared/_services/utility.service";
import {BrowsePerson} from "../_models/person/browse-person";
import {Chapter} from "../_models/chapter";
import {StandaloneChapter} from "../_models/standalone-chapter";
import {TextResonse} from "../_types/text-response";
@ -29,6 +27,10 @@ export class PersonService {
return this.httpClient.get<Person | null>(this.baseUrl + `person?name=${name}`);
}
searchPerson(name: string) {
return this.httpClient.get<Array<Person>>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`);
}
getRolesForPerson(personId: number) {
return this.httpClient.get<Array<PersonRole>>(this.baseUrl + `person/roles?personId=${personId}`);
}
@ -55,4 +57,15 @@ export class PersonService {
downloadCover(personId: number) {
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse);
}
isValidAlias(personId: number, alias: string) {
return this.httpClient.get<boolean>(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe(
map(valid => valid + '' === 'true')
);
}
mergePerson(destId: number, srcId: number) {
return this.httpClient.post<Person>(this.baseUrl + 'person/merge', {destId, srcId});
}
}

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

@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit {
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
return {id: 0, name: title, aliases: [], role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
});
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

View file

@ -53,7 +53,7 @@
<app-match-series-result-item [item]="item" [isDarkMode]="(themeService.isDarkMode$ | async)!" (selected)="selectMatch($event)"></app-match-series-result-item>
} @empty {
@if (!isLoading) {
{{t('no-results')}}
<p>{{t('no-results')}}</p>
}
}
}

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

@ -521,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
return {id: 0, name: title, aliases: [], description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
});
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

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" class="actionables" 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" class="actionables"/>
</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 {
@ -279,7 +279,11 @@ export class ChapterDetailComponent implements OnInit {
this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 ||
(this.chapter.tags || []).length > 0 || this.chapter.webLinks.length > 0;
if (!this.showDetailsTab && this.activeTabId === TabID.Details) {
this.activeTabId = TabID.Reviews;
}
this.isLoading = false;
this.cdRef.markForCheck();
});
@ -335,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) => {
@ -356,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

@ -118,7 +118,14 @@
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
</div>
<div class="ms-1">
<div>{{item.name}}</div>
<div>
{{item.name}}
</div>
@if (item.aliases.length > 0) {
<span class="small-text">
{{t('person-aka-status')}}
</span>
}
</div>
</div>
</ng-template>
@ -206,7 +213,7 @@
</div>
}
}
</div>
</nav>

View file

@ -149,3 +149,7 @@
}
}
}
.small-text {
font-size: 0.8rem;
}

View file

@ -96,6 +96,19 @@
</ng-template>
</li>
<li [ngbNavItem]="TabID.Aliases">
<a ngbNavLink>{{t(TabID.Aliases)}}</a>
<ng-template ngbNavContent>
<h5>{{t('aliases-label')}}</h5>
<div class="text-muted mb-2">{{t('aliases-tooltip')}}</div>
<app-edit-list [items]="person.aliases"
[asyncValidators]="[aliasValidator()]"
(updateItems)="person.aliases = $event"
[errorMessage]="t('alias-overlap')"
[label]="t('aliases-label')"/>
</ng-template>
</li>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>

View file

@ -1,6 +1,14 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {
AbstractControl,
AsyncValidatorFn,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
Validators
} from "@angular/forms";
import {Person} from "../../../_models/metadata/person";
import {
NgbActiveModal,
@ -14,14 +22,16 @@ import {
import {PersonService} from "../../../_services/person.service";
import {translate, TranslocoDirective} from '@jsverse/transloco';
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
import {forkJoin} from "rxjs";
import {forkJoin, map, of} from "rxjs";
import {UploadService} from "../../../_services/upload.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {AccountService} from "../../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
enum TabID {
General = 'general-tab',
Aliases = 'aliases-tab',
CoverImage = 'cover-image-tab',
}
@ -37,7 +47,8 @@ enum TabID {
NgbNavOutlet,
CoverImageChooserComponent,
SettingItemComponent,
NgbNavLink
NgbNavLink,
EditListComponent
],
templateUrl: './edit-person-modal.component.html',
styleUrl: './edit-person-modal.component.scss',
@ -117,6 +128,7 @@ export class EditPersonModalComponent implements OnInit {
// @ts-ignore
malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10),
hardcoverId: this.editForm.get('hardcoverId')!.value || '',
aliases: this.person.aliases,
};
apis.push(this.personService.updatePerson(person));
@ -165,4 +177,21 @@ export class EditPersonModalComponent implements OnInit {
});
}
aliasValidator(): AsyncValidatorFn {
return (control: AbstractControl) => {
const name = control.value;
if (!name || name.trim().length === 0) {
return of(null);
}
return this.personService.isValidAlias(this.person.id, name).pipe(map(valid => {
if (valid) {
return null;
}
return { 'invalidAlias': {'alias': name} } as ValidationErrors;
}));
}
}
}

View file

@ -0,0 +1,65 @@
<ng-container *transloco="let t; prefix:'merge-person-modal'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">
{{t('title', {personName: this.person.name})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
</div>
<div class="modal-body scrollable-modal d-flex flex-column" style="min-height: 300px;" >
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('src')" [subtitle]="t('merge-warning')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead [settings]="typeAheadSettings" (selectedData)="updatePerson($event)" [unFocus]="typeAheadUnfocus">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
@if (mergee) {
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<h5>{{t('alias-title')}}</h5>
<app-badge-expander [items]="allNewAliases()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
{{item}}
</ng-template>
</app-badge-expander>
@if (knownFor$ | async; as knownFor) {
<h5 class="mt-2">{{t('known-for-title')}}</h5>
<app-badge-expander [items]="knownFor">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
{{item.name}}
</ng-template>
</app-badge-expander>
}
</div>
</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="mergee === null" >{{t('save')}}</button>
</div>
</ng-container>

View file

@ -0,0 +1,101 @@
import {Component, DestroyRef, EventEmitter, inject, Input, OnInit} from '@angular/core';
import {Person} from "../../../_models/metadata/person";
import {PersonService} from "../../../_services/person.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {ToastrService} from "ngx-toastr";
import {TranslocoDirective} from "@jsverse/transloco";
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
import {TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings";
import {map} from "rxjs/operators";
import {UtilityService} from "../../../shared/_services/utility.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {Observable, of} from "rxjs";
import {Series} from "../../../_models/series";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe} from "@angular/common";
@Component({
selector: 'app-merge-person-modal',
imports: [
TranslocoDirective,
TypeaheadComponent,
SettingItemComponent,
BadgeExpanderComponent,
AsyncPipe
],
templateUrl: './merge-person-modal.component.html',
styleUrl: './merge-person-modal.component.scss'
})
export class MergePersonModalComponent implements OnInit {
private readonly personService = inject(PersonService);
public readonly utilityService = inject(UtilityService);
private readonly destroyRef = inject(DestroyRef);
private readonly modal = inject(NgbActiveModal);
protected readonly toastr = inject(ToastrService);
typeAheadSettings!: TypeaheadSettings<Person>;
typeAheadUnfocus = new EventEmitter<string>();
@Input({required: true}) person!: Person;
mergee: Person | null = null;
knownFor$: Observable<Series[]> | null = null;
save() {
if (!this.mergee) {
this.close();
return;
}
this.personService.mergePerson(this.person.id, this.mergee.id).subscribe(person => {
this.modal.close({success: true, person: person});
})
}
close() {
this.modal.close({success: false, person: this.person});
}
ngOnInit(): void {
this.typeAheadSettings = new TypeaheadSettings<Person>();
this.typeAheadSettings.minCharacters = 0;
this.typeAheadSettings.multiple = false;
this.typeAheadSettings.addIfNonExisting = false;
this.typeAheadSettings.id = "merge-person-modal-typeahead";
this.typeAheadSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name;
}
this.typeAheadSettings.fetchFn = (filter: string) => {
if (filter.length == 0) return of([]);
return this.personService.searchPerson(filter).pipe(map(people => {
return people.filter(p => this.utilityService.filter(p.name, filter) && p.id != this.person.id);
}));
};
this.typeAheadSettings.trackByIdentityFn = (index, value) => `${value.name}_${value.id}`;
}
updatePerson(people: Person[]) {
if (people.length == 0) return;
this.typeAheadUnfocus.emit(this.typeAheadSettings.id);
this.mergee = people[0];
this.knownFor$ = this.personService.getSeriesMostKnownFor(this.mergee.id)
.pipe(takeUntilDestroyed(this.destroyRef));
}
protected readonly FilterField = FilterField;
allNewAliases() {
if (!this.mergee) return [];
return [this.mergee.name, ...this.mergee.aliases]
}
}

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) {
@ -43,15 +43,43 @@
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
<div class="row g-0 mt-2">
<app-read-more [text]="person.description || defaultSummaryText"></app-read-more>
<app-read-more [maxLength]="500" [text]="person.description || t('no-info')"></app-read-more>
@if (person.aliases.length > 0) {
<span class="fw-bold mt-2">{{t('aka-title')}}</span>
<div>
<app-badge-expander [items]="person.aliases"
[itemsTillExpander]="6">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<span>{{item}}</span>
</ng-template>
</app-badge-expander>
</div>
}
@if (roles$ | async; as roles) {
<div class="mt-1">
<h5>{{t('all-roles')}}</h5>
@for(role of roles; track role) {
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>
}
</div>
@if (roles.length > 0) {
<span class="fw-bold mt-2">{{t('all-roles')}}</span>
<div>
<app-badge-expander [items]="roles"
[itemsTillExpander]="6">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="loadFilterByRole(item)">{{item | personRole}}</a>
</ng-template>
</app-badge-expander>
</div>
}
<!-- -->
<!-- <div class="mt-1">-->
<!-- <h5>{{t('all-roles')}}</h5>-->
<!-- @for(role of roles; track role) {-->
<!-- <app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>-->
<!-- }-->
<!-- </div>-->
}
</div>

View file

@ -1,31 +1,30 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
ElementRef,
Inject,
inject, OnInit,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router";
import {PersonService} from "../_services/person.service";
import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs";
import {Person, PersonRole} from "../_models/metadata/person";
import {AsyncPipe, NgStyle} from "@angular/common";
import {AsyncPipe} from "@angular/common";
import {ImageComponent} from "../shared/image/image.component";
import {ImageService} from "../_services/image.service";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeComponent, 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 {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
import {Series} from "../_models/series";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
@ -42,28 +41,38 @@ import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service";
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component";
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
interface PersonMergeEvent {
srcId: number,
dstId: number,
dstName: number,
}
@Component({
selector: 'app-person-detail',
imports: [
AsyncPipe,
ImageComponent,
SideNavCompanionBarComponent,
ReadMoreComponent,
TagBadgeComponent,
PersonRolePipe,
CarouselReelComponent,
CardItemComponent,
CardActionablesComponent,
TranslocoDirective,
ChapterCardComponent,
SafeUrlPipe
],
templateUrl: './person-detail.component.html',
styleUrl: './person-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-person-detail',
imports: [
AsyncPipe,
ImageComponent,
SideNavCompanionBarComponent,
ReadMoreComponent,
PersonRolePipe,
CarouselReelComponent,
CardItemComponent,
CardActionablesComponent,
TranslocoDirective,
ChapterCardComponent,
SafeUrlPipe,
BadgeExpanderComponent
],
templateUrl: './person-detail.component.html',
styleUrl: './person-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonDetailComponent {
export class PersonDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly filterUtilityService = inject(FilterUtilitiesService);
@ -77,8 +86,9 @@ export class PersonDetailComponent {
protected readonly licenseService = inject(LicenseService);
private readonly themeService = inject(ThemeService);
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;
@ -88,11 +98,11 @@ export class PersonDetailComponent {
roles$: Observable<PersonRole[]> | null = null;
roles: PersonRole[] | null = null;
works$: Observable<Series[]> | null = null;
defaultSummaryText = 'No information about this Person';
filter: SeriesFilterV2 | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {};
anilistUrl: string = '';
private readonly personSubject = new BehaviorSubject<Person | null>(null);
protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
if (p?.aniListId) {
@ -118,43 +128,58 @@ export class PersonDetailComponent {
return this.personService.get(personName);
}),
tap((person) => {
if (person == null) {
this.toastr.error(translate('toasts.unauthorized-1'));
this.router.navigateByUrl('/home');
return;
}
this.person = person;
this.personSubject.next(person); // emit the person data for subscribers
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
// Fetch roles and process them
this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
tap(roles => {
this.roles = roles;
this.filter = this.createFilter(roles);
this.chaptersByRole = {}; // Reset chaptersByRole for each person
// Populate chapters by role
roles.forEach(role => {
this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
.pipe(takeUntilDestroyed(this.destroyRef));
});
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
);
// Fetch series known for this person
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
takeUntilDestroyed(this.destroyRef)
);
this.setPerson(person);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
ngOnInit(): void {
this.messageHubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
if (message.event !== EVENTS.PersonMerged) return;
const event = message.payload as PersonMergeEvent;
if (event.srcId !== this.person?.id) return;
this.router.navigate(['person', event.dstName]);
});
}
private setPerson(person: Person) {
this.person = person;
this.personSubject.next(person); // emit the person data for subscribers
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
// Fetch roles and process them
this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
tap(roles => {
this.roles = roles;
this.filter = this.createFilter(roles);
this.chaptersByRole = {}; // Reset chaptersByRole for each person
// Populate chapters by role
roles.forEach(role => {
this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
.pipe(takeUntilDestroyed(this.destroyRef));
});
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
);
// Fetch series known for this person
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
takeUntilDestroyed(this.destroyRef)
);
}
createFilter(roles: PersonRole[]) {
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
filter.combination = FilterCombination.Or;
@ -229,14 +254,27 @@ export class PersonDetailComponent {
}
});
break;
case (Action.Merge):
this.mergePersonAction();
break;
default:
break;
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.person);
}
private mergePersonAction() {
const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions);
ref.componentInstance.person = this.person;
ref.closed.subscribe(r => {
if (r.success) {
// Reload the person data, as relations may have changed
this.personService.get(r.person.name).subscribe(person => {
this.setPerson(person!);
this.cdRef.markForCheck();
})
}
});
}
}

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

@ -22,7 +22,7 @@
</h4>
<div class="subtitle mt-2 mb-2">
@if (series.localizedName !== series.name) {
@if (series.localizedName !== series.name && series.localizedName) {
<span>{{series.localizedName | defaultValue}}</span>
}
</div>
@ -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>
@ -130,7 +130,7 @@
<span class="fw-bold">{{t('publication-status-title')}}</span>
<div>
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
<a class="dark-exempt btn-icon" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
<a class="dark-exempt btn-icon font-size" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata!.publicationStatus)"
href="javascript:void(0);"
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
{{pubStatus}}

View file

@ -30,3 +30,7 @@
:host ::ng-deep .card-actions.btn-actions .btn {
padding: 0.375rem 0.75rem;
}
.font-size {
font-size: 0.8rem;
}

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

@ -5,4 +5,11 @@
.collapsed {
height: 35px;
overflow: hidden;
}
}
::ng-deep .badge-expander .content {
a,
span {
font-size: 0.8rem;
}
}

View file

@ -1,6 +1,7 @@
<form [formGroup]="form" *transloco="let t">
<div formArrayName="items">
@for(item of ItemsArray.controls; let i = $index; track i) {
<!-- We are tracking items, as the index will not always point towards the same item. -->
@for(item of ItemsArray.controls; let i = $index; track item; let last = $last) {
<div class="row g-0 mb-3">
<div class="col-lg-10 col-md-12 pe-2">
<div class="mb-3">
@ -11,21 +12,30 @@
[formControlName]="i"
id="item--{{i}}"
>
@if (item.dirty && item.touched && errorMessage) {
@if (item.status === "INVALID") {
<div id="item--{{i}}-error" class="invalid-feedback" style="display: inline-block">
{{errorMessage}}
</div>
}
}
</div>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary me-1" (click)="add()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.add')}}</span>
</button>
<div class="col-lg-2">
<button
class="btn btn-secondary"
class="btn btn-danger me-2"
(click)="remove(i)"
[disabled]="ItemsArray.length === 1"
>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.remove')}}</span>
</button>
@if (last){
<button class="btn btn-secondary " (click)="add()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.add')}}</span>
</button>
}
</div>
</div>
}

View file

@ -9,7 +9,7 @@ import {
OnInit,
Output
} from '@angular/core';
import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {AsyncValidatorFn, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidatorFn} from "@angular/forms";
import {TranslocoDirective} from "@jsverse/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
@ -28,6 +28,10 @@ export class EditListComponent implements OnInit {
@Input({required: true}) items: Array<string> = [];
@Input({required: true}) label = '';
@Input() validators: ValidatorFn[] = []
@Input() asyncValidators: AsyncValidatorFn[] = [];
// TODO: Make this more dynamic based on which validator failed
@Input() errorMessage: string | null = null;
@Output() updateItems = new EventEmitter<Array<string>>();
form: FormGroup = new FormGroup({items: new FormArray([])});
@ -39,6 +43,9 @@ export class EditListComponent implements OnInit {
ngOnInit() {
this.items.forEach(item => this.addItem(item));
if (this.items.length === 0) {
this.addItem("");
}
this.form.valueChanges.pipe(
@ -51,7 +58,7 @@ export class EditListComponent implements OnInit {
}
createItemControl(value: string = ''): FormControl {
return new FormControl(value, []);
return new FormControl(value, this.validators, this.asyncValidators);
}
add() {
@ -69,6 +76,7 @@ export class EditListComponent implements OnInit {
if (this.ItemsArray.length === 1) {
this.ItemsArray.at(0).setValue('');
this.emit();
this.cdRef.markForCheck();
return;
}

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

@ -130,7 +130,8 @@ export class LibrarySettingsModalComponent implements OnInit {
get IsMetadataDownloadEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel
|| libType === LibraryType.ComicVine || libType === LibraryType.Comic;
}
ngOnInit(): void {
@ -256,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

@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit {
* When triggered, will focus the input if the passed string matches the id
*/
@Input() focus: EventEmitter<string> | undefined;
/**
* When triggered, will unfocus the input if the passed string matches the id
*/
@Input() unFocus: EventEmitter<string> | undefined;
@Output() selectedData = new EventEmitter<any[] | any>();
@Output() newItemAdded = new EventEmitter<any[] | any>();
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit {
});
}
if (this.unFocus) {
this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => {
if (this.settings.id !== id) return;
this.hasFocus = false;
});
}
this.init();
}

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

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, kliknutím zobrazíte"
},
"review-series-modal": {
"title": "Upravit recenzi",
"review-label": "Recenze",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Recenze musí mít alespoň {{count}} znaků",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}'s Recenze",
@ -81,7 +72,8 @@
"your-review": "Toto je vaše recenze",
"external-review": "Externí recenze",
"local-review": "Místní recenze",
"rating-percentage": "Hodnocení {{r}} %"
"rating-percentage": "Hodnocení {{r}} %",
"critic": "kritik"
},
"want-to-read": {
"title": "Chci číst",
@ -785,9 +777,7 @@
"publication-status-tooltip": "Stav publikace",
"release-date-title": "Vydání",
"time-left-alt": "Zbývající čas",
"user-reviews-local": "Místní hodnocení",
"publication-status-title": "Publikace",
"user-reviews-plus": "Externí hodnocení",
"weblinks-title": "Odkazy",
"more-alt": "Více",
"pages-count": "{{num}} stran",
@ -821,7 +811,8 @@
"close": "{{common.close}}",
"kavita-rating-title": "Vaše hodnocení",
"entry-label": "Zobrazit podrobnosti",
"kavita-tooltip": "Vaše hodnocení + celkové"
"kavita-tooltip": "Vaše hodnocení + celkové",
"critic": "{{review-card.critic}}"
},
"badge-expander": {
"more-items": "a {{počet}} více"
@ -1460,7 +1451,8 @@
"skip-alt": "Přeskočit na hlavní obsah",
"server-settings": "Nastavení serveru",
"settings": "Nastavení",
"announcements": "Oznámení"
"announcements": "Oznámení",
"person-aka-status": "Shoduje se s aliasem"
},
"promoted-icon": {
"promoted": "{{common.promoted}}"
@ -2009,7 +2001,10 @@
"new-collection": "Nová sbírka",
"match": "Shoda",
"match-tooltip": "Přiřadit série s Kavitou+ manuálně",
"reorder": "Přeuspořádat"
"reorder": "Přeuspořádat",
"rename": "Přejmenovat",
"rename-tooltip": "Přejmenovat inteligentní filtr",
"merge": "Sloučit"
},
"changelog-update-item": {
"changed": "Změněno",
@ -2045,7 +2040,9 @@
"known-for-title": "Známý díky",
"individual-role-title": "Jako {{role}}",
"browse-person-title": "Všechny práce {{name}}",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
"no-info": "Žádné informace o této osobě",
"aka-title": "Známé také jako "
},
"draggable-ordered-list": {
"reorder-label": "Přeuspořádat",
@ -2064,7 +2061,7 @@
},
"match-series-modal": {
"no-results": "Nelze najít shodu.Zkuste přidat url od podporovaného poskytovatele a zkuste to znovu.",
"query-tooltip": "Zadejte název série, url AniListu/MyAnimeListu. Url budou používat přímé vyhledávání.",
"query-tooltip": "Zadejte název série, AniList/MyAnimeList/ComicBookRoundup url. Url budou používat přímé vyhledávání.",
"title": "Shoda {{seriesName}}",
"close": "{{common.close}}",
"save": "{{common.save}}",
@ -2131,7 +2128,8 @@
"chapter-count": "{{common.chapter-count}}",
"releasing": "Vychází",
"details": "Zobrazit stránku",
"updating-metadata-status": "Aktualizace metadat"
"updating-metadata-status": "Aktualizace metadat",
"issue-count": "{{common.issue-count}}"
},
"pdf-reader": {
"incognito-mode": "Anonymní režim",
@ -2179,7 +2177,11 @@
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
"close": "{{common.close}}",
"anilist-id-label": "AniList ID",
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/"
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/",
"aliases-tab": "Aliasy",
"aliases-label": "Upravit aliasy",
"alias-overlap": "Tento alias již odkazuje na jinou osobu nebo je jménem této osoby, zvažte jejich sloučení.",
"aliases-tooltip": "Pokud je série označena aliasem osoby, je tato osoba přiřazena, místo aby byla vytvořena nová osoba. Při odstranění aliasu je nutné sérii znovu prohledat, aby se změna zachytila."
},
"errors": {
"delete-theme-in-use": "Motiv je aktuálně používán alespoň jedním uživatelem, nelze odstranit",
@ -2458,7 +2460,8 @@
"match-success": "Správně sladěné série",
"webtoon-override": "Přepnutí do režimu Webtoon kvůli obrázkům představujícím webtoon.",
"scrobble-gen-init": "Vytvořena úloha pro generování událostí scrobble z historie čtení, hodnocení v minulosti a jejich synchronizaci s připojenými službami.",
"confirm-delete-multiple-volumes": "Jste si jisti, že chcete odstranit {{count}} svazků? Soubory na disku se tím nezmění."
"confirm-delete-multiple-volumes": "Jste si jisti, že chcete odstranit {{count}} svazků? Soubory na disku se tím nezmění.",
"series-added-want-to-read": "Série přidána ze seznamu Chci číst"
},
"preferences": {
"split-right-to-left": "Rozdělit zprava doleva",
@ -2620,7 +2623,18 @@
"localized-name-tooltip": "Povolit zápis lokalizovaného názvu při odemknutí pole. Kavita se pokusí provést nejlepší odhad.",
"enable-cover-image-label": "Obrázek na obálce",
"overrides-description": "Povolit Kavitě zápis přes uzamčená pole.",
"overrides-label": "Přepisuje"
"overrides-label": "Přepisuje",
"enable-chapter-release-date-label": "Datum vydání",
"enable-chapter-publisher-label": "Vydavatel",
"enable-chapter-publisher-tooltip": "Umožnit napsání vydavatele kapitoly/vydání",
"enable-chapter-cover-label": "Obálka kapitoly",
"enable-chapter-cover-tooltip": "Umožnit nastavení obálky kapitoly/vydání",
"chapter-header": "Pole kapitoly",
"enable-chapter-release-date-tooltip": "Povolit datum vydání kapitoly/vydání, které má být napsáno",
"enable-chapter-title-label": "Titulek",
"enable-chapter-title-tooltip": "Umožnit napsání názvu kapitoly/vydání",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}"
},
"metadata-setting-field-pipe": {
"people": "{{tabs.people-tab}}",
@ -2631,7 +2645,12 @@
"covers": "Obálky",
"genres": "{{metadata-fields.genres-title}}",
"summary": "{{filter-field-pipe.summary}}",
"publication-status": "{{edit-series-modal.publication-status-title}}"
"publication-status": "{{edit-series-modal.publication-status-title}}",
"chapter-title": "Název (kapitola)",
"chapter-covers": "Obálky (kapitola)",
"chapter-release-date": "Datum vydání (kapitola)",
"chapter-summary": "Shrnutí (kapitola)",
"chapter-publisher": "{{person-role-pipe.publisher}} (Kapitola)"
},
"role-localized-pipe": {
"download": "Stažené",
@ -2649,5 +2668,27 @@
"trace": "Sledovat",
"warning": "Varování",
"critical": "Kritické"
},
"review-modal": {
"title": "Upravit recenzi",
"min-length": "Recenze musí mít alespoň {{count}} znaků",
"required": "{{validation.required-field}}",
"delete": "{{common.delete}}",
"save": "{{common.save}}",
"review-label": "Recenze",
"close": "{{common.close}}"
},
"reviews": {
"user-reviews-local": "Místní recenze",
"user-reviews-plus": "Externí recenze"
},
"merge-person-modal": {
"title": "{{personName}}",
"close": "{{common.close}}",
"save": "{{common.save}}",
"src": "Sloučit osoby",
"merge-warning": "Pokud budete pokračovat, vybraná osoba bude odstraněna. Jméno vybrané osoby bude přidáno jako alias a všechny její role budou převedeny.",
"alias-title": "Nové aliasy",
"known-for-title": "Známý pro"
}
}

View file

@ -16,12 +16,6 @@
"filter-label": "{{common.filter}}",
"special": "{{entity-title.special}}"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}"
},

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, klicke zum Anzeigen"
},
"review-series-modal": {
"title": "Rezension bearbeiten",
"review-label": "Rezension",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Rezension muss mindestens {{count}} Zeichen lang sein",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}'s Rezension",
@ -762,8 +753,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Zu diesem Band gibt es keine Kapitel. Kann nicht gelesen werden.",
"cover-change": "Es kann bis zu einer Minute dauern, bis Ihr Browser das Bild aktualisiert hat. Bis dahin kann auf einigen Seiten noch das alte Bild angezeigt werden.",
"user-reviews-local": "Lokale Bewertungen",
"user-reviews-plus": "Externe Bewertungen",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -55,15 +55,6 @@
"spoiler": {
"click-to-show": "Spoiler, κάντε κλικ για να το δείτε"
},
"review-series-modal": {
"title": "Επεξεργασία Κριτικής",
"review-label": "Κριτική",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Η κριτική πρέπει να περιέχει τουλάχιστον {{count}} χαρακτήρες",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Η κριτική του {{username}}",
@ -290,8 +281,6 @@
"info-tab": "{{tabs.info-tab}}",
"recommendations-tab": "{{tabs.recommendations-tab}}",
"no-pages": "{{toasts.no-pages}}",
"user-reviews-local": "Τοπικές Κριτικές",
"user-reviews-plus": "Εξωτερικές Κριτικές",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -1003,7 +1003,7 @@
"save": "{{common.save}}",
"no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
"query-label": "Query",
"query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.",
"query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.",
"dont-match-label": "Do not Match",
"dont-match-tooltip": "Opt this series from matching and scrobbling",
"search": "Search"
@ -1103,12 +1103,14 @@
},
"person-detail": {
"aka-title": "Also known as ",
"known-for-title": "Known For",
"individual-role-title": "As a {{role}}",
"browse-person-title": "All Works of {{name}}",
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
"all-roles": "Roles",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
"no-info": "No information about this Person"
},
"library-settings-modal": {
@ -1857,7 +1859,8 @@
"logout": "Logout",
"all-filters": "Smart Filters",
"nav-link-header": "Navigation Options",
"close": "{{common.close}}"
"close": "{{common.close}}",
"person-aka-status": "Matches an alias"
},
"promoted-icon": {
@ -2246,6 +2249,7 @@
"title": "{{personName}} Details",
"general-tab": "{{edit-series-modal.general-tab}}",
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
"aliases-tab": "Aliases",
"loading": "{{common.loading}}",
"close": "{{common.close}}",
"name-label": "{{edit-series-modal.name-label}}",
@ -2263,7 +2267,20 @@
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
"save": "{{common.save}}",
"download-coversdb": "Download from CoversDB"
"download-coversdb": "Download from CoversDB",
"aliases-label": "Edit aliases",
"alias-overlap": "This alias already points towards another person or is the name of this person, consider merging them.",
"aliases-tooltip": "When a series is tagged with an alias of a person, the person is assigned rather than creating a new person. When deleting an alias, you'll have to rescan the series for the change to be picked up."
},
"merge-person-modal": {
"title": "{{personName}}",
"close": "{{common.close}}",
"save": "{{common.save}}",
"src": "Merge Person",
"merge-warning": "If you proceed, the selected person will be removed. The selected person's name will be added as an alias, and all their roles will be transferred.",
"alias-title": "New aliases",
"known-for-title": "Known for"
},
"day-breakdown": {
@ -2606,6 +2623,7 @@
"entity-unread": "{{name}} is now unread",
"mark-read": "Marked as Read",
"mark-unread": "Marked as Unread",
"series-added-want-to-read": "Series added from Want to Read list",
"series-removed-want-to-read": "Series removed from Want to Read list",
"series-deleted": "Series deleted",
"delete-review": "Are you sure you want to delete your review?",
@ -2780,7 +2798,8 @@
"match-tooltip": "Match Series with Kavita+ manually",
"reorder": "Reorder",
"rename": "Rename",
"rename-tooltip": "Rename the Smart Filter"
"rename-tooltip": "Rename the Smart Filter",
"merge": "Merge"
},
"preferences": {

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Alerta de spoiler, haz clic para mostrar"
},
"review-series-modal": {
"title": "Editar reseña",
"review-label": "Reseña",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "La reseña debe tener al menos {{count}} caracteres",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Reseña de {{username}}",
@ -757,8 +748,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "No existen capítulos en este volumen. No se puede leer.",
"cover-change": "Puede tomar hasta un minuto para que tu navegador actualice la imagen. Espera hasta entonces. La imagen antigua se puede mostrar en algunas páginas.",
"user-reviews-local": "Reseñas locales",
"user-reviews-plus": "Reseñas externas",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -56,15 +56,6 @@
"spoiler": {
"click-to-show": "Potentsiaalselt mittesoovitav eelinfo sisu kohta - kliki seda, et näidata"
},
"review-series-modal": {
"title": "Muuda ülevaade",
"review-label": "Ülevaade",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Ülevaade peab olema vähemalt {{count}} märki pikk",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}} ülevaade",

View file

@ -128,15 +128,6 @@
"spoiler": {
"click-to-show": "Spoileri, napsauta näyttääksesi"
},
"review-series-modal": {
"title": "Muokkaa arvostelua",
"review-label": "Arvostelu",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Arvostelussa on oltava vähintään {{count}} merkkiä",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"external-mod": "(external)",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Divulgâcheur, cliquez afin d'afficher"
},
"review-series-modal": {
"title": "Éditer la critique",
"review-label": "Critique",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "La critique doit comporter au moins {{count}} caractères",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Critique de {{username}}",
@ -762,8 +753,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Il n'y a aucun chapitre dans ce volume. Il ne peut être lu.",
"cover-change": "L'actualisation de l'image par votre navigateur peut prendre jusqu'à une minute. En attendant, l'ancienne image peut être affichée sur certaines pages.",
"user-reviews-local": "Commentaires locaux",
"user-reviews-plus": "Commentaires externes",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, cliceáil chun a thaispeáint"
},
"review-series-modal": {
"title": "Cuir Athbhreithniú in Eagar",
"review-label": "Athbhreithniú",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Ní mór an t-athbhreithniú a bheith ar a laghad {{count}} carachtair",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Léirmheas {{username}}",
@ -81,7 +72,8 @@
"your-review": "Is é seo d'athbhreithniú",
"external-review": "Athbhreithniú Seachtrach",
"local-review": "Athbhreithniú Áitiúil",
"rating-percentage": "Rátáil {{r}}%"
"rating-percentage": "Rátáil {{r}}%",
"critic": "criticeoir"
},
"want-to-read": {
"title": "Ag iarraidh léamh",
@ -762,8 +754,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Níl aon chaibidlí san imleabhar seo. Ní féidir léamh.",
"cover-change": "Is féidir leis suas le nóiméad a thógáil le do bhrabhsálaí an íomhá a athnuachan. Go dtí sin, is féidir an tseaníomhá a thaispeáint ar roinnt leathanach.",
"user-reviews-local": "Léirmheasanna Áitiúla",
"user-reviews-plus": "Léirmheasanna Seachtracha",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",
@ -825,7 +815,8 @@
"entry-label": "Féach Sonraí",
"kavita-tooltip": "Do Rátáil + Tríd is tríd",
"kavita-rating-title": "Do Rátáil",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "{{review-card.critic}}"
},
"badge-expander": {
"more-items": "agus {{count}} níos mó"
@ -1522,7 +1513,8 @@
"logout": "Logáil amach",
"all-filters": "Scagairí Cliste",
"nav-link-header": "Roghanna Nascleanúna",
"close": "{{common.close}}"
"close": "{{common.close}}",
"person-aka-status": "Meaitseálann leasainm"
},
"promoted-icon": {
"promoted": "{{common.promoted}}"
@ -2260,7 +2252,9 @@
"bulk-delete-libraries": "An bhfuil tú cinnte gur mhaith leat {{count}} leabharlann a scriosadh?",
"match-success": "Sraith a mheaitseáil i gceart",
"webtoon-override": "Ag aistriú go mód Webtoon mar gheall ar íomhánna a léiríonn webtoon.",
"scrobble-gen-init": "Cheangail post chun teagmhais scrobble a ghiniúint ó stair léitheoireachta agus rátálacha san am a chuaigh thart, agus iad á sioncronú le seirbhísí ceangailte."
"scrobble-gen-init": "Cheangail post chun teagmhais scrobble a ghiniúint ó stair léitheoireachta agus rátálacha san am a chuaigh thart, agus iad á sioncronú le seirbhísí ceangailte.",
"confirm-delete-multiple-volumes": "An bhfuil tú cinnte gur mian leat {{count}} imleabhar a scriosadh? Ní athróidh sé comhaid ar an diosca.",
"series-added-want-to-read": "Sraith curtha leis ón liosta Ar Mhaith Leat Léamh"
},
"read-time-pipe": {
"less-than-hour": "<1 Uair",
@ -2333,7 +2327,10 @@
"copy-settings": "Cóipeáil Socruithe Ó",
"match": "Meaitseáil",
"match-tooltip": "Sraith Meaitseála le Kavita+ de láimh",
"reorder": "Athordú"
"reorder": "Athordú",
"rename": "Athainmnigh",
"rename-tooltip": "Athainmnigh an Scagaire Cliste",
"merge": "Cumaisc"
},
"preferences": {
"left-to-right": "Clé go Deas",
@ -2472,7 +2469,9 @@
"browse-person-by-role-title": "Gach Oibre de {{name}} mar {{role}}",
"individual-role-title": "Mar {{role}}",
"all-roles": "Róil",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
"aka-title": "Ar a dtugtar freisin ",
"no-info": "Gan aon eolas faoin Duine seo"
},
"edit-person-modal": {
"general-tab": "{{edit-series-modal.general-tab}}",
@ -2495,7 +2494,11 @@
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/",
"cover-image-description-extra": "Nó is féidir leat clúdach a íoslódáil ó CoversDB má tá sé ar fáil.",
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
"download-coversdb": "Íoslódáil ó CoverDB"
"download-coversdb": "Íoslódáil ó CoverDB",
"aliases-label": "Cuir leasainmneacha in eagar",
"aliases-tab": "Leasainmneacha",
"aliases-tooltip": "Nuair a chuirtear leasainm duine ar shraith, sanntar an duine sin seachas duine nua a chruthú. Agus leasainm á scriosadh, beidh ort an tsraith a athscanadh le go dtabharfar faoi deara an t-athrú.",
"alias-overlap": "Tá an leasainm seo ag tagairt do dhuine eile cheana féin nó is é ainm an duine seo é, smaoinigh ar iad a chumasc."
},
"new-version-modal": {
"description": "Tá leagan nua de Kavita ar fáil. Athnuaigh le nuashonrú.",
@ -2532,7 +2535,7 @@
"title": "Meaitseáil {{ seriesName}}",
"description": "Roghnaigh meaitseáil chun meiteashonraí Kavita+ a athshreangú agus imeachtaí scrobble a athghiniúint. Is féidir Don't Match a úsáid chun Kavita a shrianadh ó mheiteashonraí a mheaitseáil agus scrobadh a dhéanamh.",
"close": "{{common.close}}",
"query-tooltip": "Cuir isteach ainm na sraithe, url AniList/MyAnimeList. Bainfidh URLanna úsáid as cuardach díreach.",
"query-tooltip": "Cuir isteach ainm na sraithe, url AniList/MyAnimeList/ComicBookRoundup. Úsáidfear cuardach díreach sna URLanna.",
"dont-match-label": "Ná Meaitseáil",
"dont-match-tooltip": "Rogha an tsraith seo ó mheaitseáil agus scrobbling",
"search": "Cuardach"
@ -2569,7 +2572,8 @@
"volume-count": "{{server-stats.volume-count}}",
"chapter-count": "{{common.chapter-count}}",
"releasing": "Ag scaoileadh",
"updating-metadata-status": "Meiteashonraí á nuashonrú"
"updating-metadata-status": "Meiteashonraí á nuashonrú",
"issue-count": "{{common.issue-count}}"
},
"manage-user-tokens": {
"description": "Seans go mbeidh gá le hathnuachan a dhéanamh ó am go chéile ar úsáideoirí a bhfuil comharthaí scrobarnaí acu. Seolfaidh Kavita ríomhphost chucu go huathoibríoch má tá ríomhphost socraithe agus má tá ríomhphost bailí acu.",
@ -2619,7 +2623,18 @@
"overrides-description": "Lig do Kavita scríobh thar réimsí faoi ghlas.",
"overrides-label": "Sáraíonn",
"localized-name-tooltip": "Ceadaigh Ainm Logánaithe a scríobh nuair a dhíghlasáiltear an réimse. Déanfaidh Kavita iarracht an buille faoi thuairim is fearr a dhéanamh.",
"enable-cover-image-tooltip": "Lig do Kavita íomhá clúdaigh na Sraithe a scríobh"
"enable-cover-image-tooltip": "Lig do Kavita íomhá clúdaigh na Sraithe a scríobh",
"enable-chapter-publisher-tooltip": "Ceadaigh dFhoilsitheoir na Caibidle/na hEagráine a bheith scríofa",
"chapter-header": "Réimsí Caibidle",
"enable-chapter-title-label": "Teideal",
"enable-chapter-title-tooltip": "Ceadaigh Teideal na Caibidle/na hEagráin a scríobh",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "Dáta Eisiúna",
"enable-chapter-release-date-tooltip": "Ceadaigh Dáta Eisiúna na Caibidle/na hEagráine a scríobh",
"enable-chapter-publisher-label": "Foilsitheoir",
"enable-chapter-cover-label": "Clúdach Caibidle",
"enable-chapter-cover-tooltip": "Ceadaigh Clúdach na Caibidle/na hEagráin a shocrú"
},
"metadata-setting-field-pipe": {
"people": "{{tabs.people-tab}}",
@ -2630,7 +2645,12 @@
"covers": "Clúdaíonn",
"publication-status": "{{edit-series-modal.publication-status-title}}",
"summary": "{{filter-field-pipe.summary}}",
"age-rating": "{{metadata-fields.age-rating-title}}"
"age-rating": "{{metadata-fields.age-rating-title}}",
"chapter-covers": "Clúdaigh (Caibidil)",
"chapter-release-date": "Dáta Eisiúna (Caibidil)",
"chapter-title": "Teideal (Caibidil)",
"chapter-summary": "Achoimre (Caibidil)",
"chapter-publisher": "{{person-role-pipe.publisher}} (Caibidil)"
},
"role-localized-pipe": {
"admin": "Riarachán",
@ -2648,5 +2668,27 @@
"critical": "Criticiúil",
"information": "Eolas",
"debug": "Dífhabhtaithe"
},
"review-modal": {
"required": "{{validation.required-field}}",
"title": "Cuir Athbhreithniú in Eagar",
"save": "{{common.save}}",
"min-length": "Ní mór don léirmheas a bheith {{count}} carachtar ar a laghad",
"delete": "{{common.delete}}",
"review-label": "Athbhreithniú",
"close": "{{common.close}}"
},
"reviews": {
"user-reviews-local": "Léirmheasanna Áitiúla",
"user-reviews-plus": "Léirmheasanna Seachtracha"
},
"merge-person-modal": {
"close": "{{common.close}}",
"alias-title": "Leasainmneacha nua",
"merge-warning": "Má leanann tú ar aghaidh, bainfear an duine roghnaithe. Cuirfear ainm an duine roghnaithe leis mar leasainm, agus aistreofar a róil go léir.",
"known-for-title": "Ar a dtugtar",
"src": "Cumaisc Duine",
"title": "{{personName}}",
"save": "{{common.save}}"
}
}

View file

@ -17,12 +17,6 @@
"filter-label": "{{common.filter}}",
"special": "{{entity-title.special}}"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}"
},

View file

@ -56,15 +56,6 @@
"spoiler": {
"click-to-show": "Spoiler, kattints a megnézéshez"
},
"review-series-modal": {
"title": "Áttekintés szerkesztése",
"review-label": "Áttekintés",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Az áttekintés legalább {{count}} karakter kell legyen",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}} áttekintése",

View file

@ -54,15 +54,6 @@
"spoiler": {
"click-to-show": "Spoiler, klik untuk tampilkan"
},
"review-series-modal": {
"title": "Ubah Ulasan",
"review-label": "Ulasan",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Ulasan harus setidaknya terdiri dari {{count}} karakter",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}"
},

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, clicca per mostrare"
},
"review-series-modal": {
"title": "Modifica recensione",
"review-label": "Recensione",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "La recensione deve avere almeno {{count}} caratteri",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Recensione di {{username}}",
@ -780,10 +771,8 @@
"reading-lists-title": "{{side-nav.reading-lists}}",
"time-to-read-alt": "{{sort-field-pipe.time-to-read}}",
"scrobbling-tooltip": "{{settings.scrobbling}}",
"user-reviews-plus": "Recensioni Esterne",
"words-count": "{{num}} Parole",
"more-alt": "Ancora",
"user-reviews-local": "Recensioni Locali",
"pages-count": "{{num}} Pagine",
"weblinks-title": "Link",
"time-left-alt": "Tempo Rimanente",

View file

@ -61,15 +61,6 @@
"spoiler": {
"click-to-show": "クリックするとネタバレが表示されます"
},
"review-series-modal": {
"title": "レビューを編集する",
"review-label": "レビュー",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "レビューは最低 {{count}}文字以上である必要があります。",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}さんのレビュー",
@ -726,8 +717,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "この巻にはチャプターが存在しません。読むことはできません。",
"cover-change": "ブラウザが画像をリフレッシュするまで最大1分かかる場合があります。その間、一部のページでは古い画像が表示される可能性があります。",
"user-reviews-local": "ローカルレビュー",
"user-reviews-plus": "外部レビュー",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "스포일러, 표시하려면 클릭"
},
"review-series-modal": {
"title": "리뷰 수정",
"review-label": "평가",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "리뷰는 적어도 {{count}}자여야 합니다",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}님의 리뷰",
@ -81,7 +72,8 @@
"your-review": "당신의 리뷰",
"external-review": "외부 리뷰",
"local-review": "로컬 리뷰",
"rating-percentage": "평점 {{r}}%"
"rating-percentage": "평점 {{r}}%",
"critic": "평점"
},
"want-to-read": {
"title": "읽고 싶어요",
@ -517,9 +509,9 @@
},
"library-type-pipe": {
"book": "책",
"comic": "만화(Comic)",
"comic": "만화 (Legacy)",
"manga": "만화(Manga)",
"comicVine": "Comic Vine",
"comicVine": "코믹(Comic)",
"image": "이미지",
"lightNovel": "라이트 노벨"
},
@ -762,8 +754,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "이 볼륨에는 챕터가 없습니다. 읽을 수 없습니다.",
"cover-change": "브라우저에서 이미지를 새로 고치는 데 최대 1분이 걸릴 수 있습니다. 그때까지는 일부 페이지에 이전 이미지가 표시될 수 있습니다.",
"user-reviews-local": "로컬 리뷰",
"user-reviews-plus": "외부 리뷰",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",
@ -789,7 +779,7 @@
"more-alt": "더 보기",
"time-left-alt": "남은 시간",
"time-to-read-alt": "{{sort-field-pipe.time-to-read}}",
"scrobbling-tooltip": "{{settings.scrobbling}}",
"scrobbling-tooltip": "{{settings.scrobbling}}: {{value}}",
"publication-status-title": "출판",
"publication-status-tooltip": "출판현황",
"on": "{{reader-settings.on}}",
@ -825,7 +815,8 @@
"entry-label": "세부정보 보기",
"kavita-tooltip": "귀하의 평가 + 전체",
"kavita-rating-title": "귀하의 평가",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "{{review-card.critic}}"
},
"badge-expander": {
"more-items": "와 {{count}} 더"
@ -861,7 +852,8 @@
"more": "더 보기",
"customize": "{{settings.customize}}",
"browse-authors": "작가 찾아보기",
"edit": "{{common.edit}}"
"edit": "{{common.edit}}",
"cancel-edit": "재정렬 닫기"
},
"library-settings-modal": {
"close": "{{common.close}}",
@ -878,7 +870,7 @@
"type-label": "유형",
"type-tooltip": "라이브러리 유형에 따라 파일 이름이 구문 분석되는 방식과 UI에 장(만화)과 이슈(만화)가 표시되는지 여부가 결정됩니다. 라이브러리 유형 간의 차이점에 대한 자세한 내용은 Wiki를 확인하세요.",
"kavitaplus-eligible-label": "Kavita+ 적격",
"kavitaplus-eligible-tooltip": "Kavita+가 정보를 가져오거나 스크로블링을 지원합니까",
"kavitaplus-eligible-tooltip": "Kavita+ 메타데이터 기능 또는 자동 추천 지원",
"folder-description": "라이브러리에 폴더 추가",
"browse": "미디어 폴더 찾아보기",
"help-us-part-1": "팔로우하여 저희를 도와주세요 ",
@ -1086,7 +1078,7 @@
"reset": "{{common.reset}}",
"test": "테스트",
"host-name-label": "호스트 이름",
"host-name-tooltip": "도메인 이름(역방향 프록시의). 이메일 기능에 필요합니다. 역방향 프록시가 없으면 임의의 URL을 사용하십시오.",
"host-name-tooltip": "이메일 기능에 필요한 역방향 프록시의 도메인 이름입니다. 역방향 프록시를 사용하지 않는 경우, http://externalip:port/ 형식 등 어떠한 URL도 사용할 수 있습니다",
"host-name-validation": "호스트 이름은 http(s)로 시작하고 /로 끝나지 않아야 합니다",
"sender-address-label": "송신자 주소",
"sender-address-tooltip": "이는 수신자가 이메일을 받을 때 볼 수 있는 이메일 주소입니다. 일반적으로 계정과 연결된 이메일 주소입니다.",
@ -1596,7 +1588,7 @@
"dry-run-step": "모의 실행",
"final-import-step": "마지막 단계",
"comicvine-parsing-label": "Comic Vine 시리즈 매칭 사용",
"cbl-repo": "커뮤니티에서 많은 독서 목록을 찾을 수 있습니다. <a href='https://github.com/DieselTech/CBL-ReadingLists' target='_blank' rel='noopener noreferrer'>보고</a>.",
"cbl-repo": "커뮤니티에서 많은 독서 목록을 찾을 수 있습니다. <a href='https://github.com/DieselTech/CBL-ReadingLists' target='_blank' rel='noopener noreferrer'>리포지토리</a>.",
"help-label": "{{common.help}}"
},
"pdf-reader": {
@ -2015,7 +2007,8 @@
"sidenav": "사이드 내비게이션",
"external-sources": "외부 소스",
"smart-filters": "스마트 필터",
"help": "{{common.help}}"
"help": "{{common.help}}",
"description": "순서 바꾸기, 표시 여부 전환, 스마트 필터 및 외부 소스를 홈페이지 또는 사이드 탐색 메뉴에 바인딩하여 Kavita의 다양한 기능을 사용자 지정할 수 있습니다."
},
"customize-dashboard-streams": {
"no-data": "대시보드에 모든 스마트 필터가 추가되었거나 아직 생성되지 않았습니다.",
@ -2258,7 +2251,8 @@
"bulk-delete-libraries": "{{count}}개의 라이브러리를 삭제하시겠습니까?",
"webtoon-override": "웹툰을 나타내는 이미지가 있어 웹툰 모드로 전환합니다.",
"match-success": "시리즈가 올바르게 일치함",
"confirm-delete-multiple-volumes": "{{count}}개의 볼륨을 삭제하시겠습니까? 디스크의 파일은 수정되지 않습니다."
"confirm-delete-multiple-volumes": "{{count}}개의 볼륨을 삭제하시겠습니까? 디스크의 파일은 수정되지 않습니다.",
"scrobble-gen-init": "과거 열람 이력 및 평가를 추적하는 이벤트를 생성하여 연결된 서비스와 동기화하는 기능을 작업 예정 목록에 추가하였습니다."
},
"read-time-pipe": {
"less-than-hour": "<1시간",
@ -2330,7 +2324,10 @@
"title": "작업",
"copy-settings": "설정 복사하기",
"match": "일치",
"match-tooltip": "Kavita+에서 시리즈를 수동으로 일치시키기"
"match-tooltip": "Kavita+에서 시리즈를 수동으로 일치시키기",
"reorder": "정렬 변경",
"rename": "이름 변경",
"rename-tooltip": "스마트 필터 이름 변경"
},
"preferences": {
"left-to-right": "왼쪽에서 오른쪽",
@ -2587,7 +2584,18 @@
"enable-genres-tooltip": "시리즈 장르를 작성할 수 있도록 허용합니다.",
"whitelist-label": "화이트리스트 태그",
"age-rating-mapping-title": "연령 등급 매핑",
"genre": "장르"
"genre": "장르",
"enable-chapter-title-label": "제목",
"chapter-header": "챕터 필드",
"enable-chapter-title-tooltip": "Chapter/Issue 제목 작성 허용",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "발매일",
"enable-chapter-release-date-tooltip": "Chapter/Issue 발매일 작성 허용",
"enable-chapter-publisher-label": "출판사",
"enable-chapter-publisher-tooltip": "Chapter/Issue 출판사 작성 허용",
"enable-chapter-cover-label": "챕터 표지",
"enable-chapter-cover-tooltip": "Chapter/Issue 커버 설정 허용"
},
"match-series-modal": {
"search": "검색",
@ -2606,7 +2614,8 @@
"details": "페이지 보기",
"updating-metadata-status": "메타데이터 업데이트",
"releasing": "출시",
"chapter-count": "{{common.chapter-count}}"
"chapter-count": "{{common.chapter-count}}",
"issue-count": "{{common.issue-count}}"
},
"email-history": {
"sent-header": "성공",
@ -2627,7 +2636,12 @@
"people": "{{tabs.people-tab}}",
"summary": "{{filter-field-pipe.summary}}",
"publication-status": "{{edit-series-modal.publication-status-title}}",
"start-date": "{{manage-metadata-settings.enable-start-date-label}}"
"start-date": "{{manage-metadata-settings.enable-start-date-label}}",
"chapter-publisher": "{{person-role-pipe.publisher}} (Chapter)",
"chapter-covers": "커버 (Chapter)",
"chapter-title": "제목 (챕터)",
"chapter-release-date": "발매일 (Chapter)",
"chapter-summary": "요약 (Chapter)"
},
"role-localized-pipe": {
"admin": "관리자",
@ -2645,5 +2659,18 @@
"trace": "추적",
"warning": "경고",
"critical": "치명적"
},
"reviews": {
"user-reviews-local": "로컬 리뷰",
"user-reviews-plus": "외부 리뷰"
},
"review-modal": {
"title": "리뷰 편집",
"review-label": "리뷰",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "리뷰는 최소 {{count}}자 이상이어야 합니다",
"required": "{{validation.required-field}}"
}
}

View file

@ -27,12 +27,6 @@
"filter-label": "{{common.filter}}",
"special": "{{entity-title.special}}"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}"
},

View file

@ -16,12 +16,6 @@
"filter-label": "{{common.filter}}",
"special": "{{entity-title.special}}"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}"
},

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, klik om te tonen"
},
"review-series-modal": {
"title": "Beoordeling bewerken",
"review-label": "Beoordeling",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Review moet minimum {{count}} characters hebben",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}s Recensie",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, kliknij by wyświetlić"
},
"review-series-modal": {
"title": "Edytuj recenzję",
"review-label": "Recenzja",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Recenzja musi zawierać co najmniej {{count}} znaków",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Recenzja {{username}}",
@ -762,8 +753,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "W tym tomie nie ma rozdziałów. Nie można odczytać.",
"cover-change": "Odświeżenie obrazu w przeglądarce może potrwać do minuty. Do tego czasu na niektórych stronach może być wyświetlany stary obraz.",
"user-reviews-local": "Lokalne recenzje",
"user-reviews-plus": "Zewnętrzne recenzje",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, clique para ver"
},
"review-series-modal": {
"title": "Editar Crítica",
"review-label": "Crítica",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "A resenha deve ter pelo menos {{count}} caracteres",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Crítica de {{username}}",
@ -762,8 +753,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Não existem capítulos neste volume. Impossível ler.",
"cover-change": "Pode levar até um minuto para a imagem ser refrescada pelo browser. Até isso acontecer, a imagem antiga será mostrada nalgumas páginas.",
"user-reviews-local": "Críticas Locais",
"user-reviews-plus": "Críticas Externas",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spoiler, clique para mostrar"
},
"review-series-modal": {
"title": "Editar Análise",
"review-label": "Análise",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "A análise deve ter pelo menos {{count}} caracteres",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}'s Análise",
@ -81,7 +72,8 @@
"your-review": "Esta é sua análise",
"external-review": "Análise Externa",
"local-review": "Análise Local",
"rating-percentage": "Avaliação {{r}}%"
"rating-percentage": "Avaliação {{r}}%",
"critic": "crítico"
},
"want-to-read": {
"title": "Quero Ler",
@ -762,8 +754,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Não há capítulos neste volume. Não posso ler.",
"cover-change": "Pode levar até um minuto para o seu navegador atualizar a imagem. Até lá, a imagem antiga pode ser exibida em algumas páginas.",
"user-reviews-local": "Análises Locais",
"user-reviews-plus": "Análises Externas",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",
@ -825,7 +815,8 @@
"entry-label": "Ver Detalhes",
"kavita-tooltip": "Sua Avaliação + Geral",
"kavita-rating-title": "Sua Avaliação",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "{{review-card.critic}}"
},
"badge-expander": {
"more-items": "e {{count}} mais"
@ -1522,7 +1513,8 @@
"logout": "Sair",
"all-filters": "Filtros Inteligentes",
"nav-link-header": "Opções de Navegação",
"close": "{{common.close}}"
"close": "{{common.close}}",
"person-aka-status": "Resultados como pseudônimos"
},
"promoted-icon": {
"promoted": "{{common.promoted}}"
@ -2261,7 +2253,8 @@
"match-success": "Série correspondida corretamente",
"webtoon-override": "Mudando para o modo Webtoon devido a imagens que representam um Webtoon.",
"scrobble-gen-init": "Enfileirou uma tarefa para gerar eventos scrobble a partir do histórico de leitura e avaliações anteriores, sincronizando-os com serviços conectados.",
"confirm-delete-multiple-volumes": "Tem certeza de que deseja excluir {{count}} volumes? Isso não modificará os arquivos no disco."
"confirm-delete-multiple-volumes": "Tem certeza de que deseja excluir {{count}} volumes? Isso não modificará os arquivos no disco.",
"series-added-want-to-read": "Série adicionada da lista Quero Ler"
},
"read-time-pipe": {
"less-than-hour": "<1 Hora",
@ -2336,7 +2329,8 @@
"match-tooltip": "Corresponder Séries com Kavita+ manualmente",
"reorder": "Reordenar",
"rename-tooltip": "Renomear o Filtro Inteligente",
"rename": "Renomear"
"rename": "Renomear",
"merge": "Mesclar"
},
"preferences": {
"left-to-right": "Esquerda para Direita",
@ -2485,7 +2479,11 @@
"cover-image-description-extra": "Alternativamente, você pode baixar uma capa do CoversDB, se disponível.",
"download-coversdb": "Baixar do CoversDB",
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
"asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}"
"asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}",
"alias-overlap": "Esse pseudônimo já aponta para outra pessoa ou é o nome dessa pessoa, considere mesclá -la.",
"aliases-label": "Editar pseudônimos",
"aliases-tab": "Pseudônimos",
"aliases-tooltip": "Quando uma série é etiquetada com um pseudônimo de uma pessoa, a pessoa é atribuída em vez de ser criada uma nova pessoa. Ao eliminar um pseudônimo, terá de voltar a analisar a série para que a alteração seja detectada."
},
"browse-authors": {
"author-count": "{{num}} Pessoas",
@ -2498,7 +2496,9 @@
"individual-role-title": "Como um {{role}}",
"known-for-title": "Conhecido Por",
"all-roles": "Papéis",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
"no-info": "Nenhuma informação sobre esta Pessoa",
"aka-title": "Também conhecido como · "
},
"manage-user-tokens": {
"anilist-header": "AniList",
@ -2561,7 +2561,7 @@
"save": "{{common.save}}",
"no-results": "Não foi possível encontrar uma correspondência. Tente adicionar o URL de um provedor compatível e tente novamente.",
"query-label": "Consulta",
"query-tooltip": "Digite o nome da série, URL AniList/MyAnimeList. As URLs usarão uma pesquisa direta.",
"query-tooltip": "Digite o nome da série, urls de Anilist/Myanimelist/ComicbookRoundup URL. As URLs serão usadas como pesquisa direta.",
"dont-match-label": "Não Fazer Correspondência",
"dont-match-tooltip": "Opte por esta série de correspondência e scrobbling",
"search": "Pesquisar"
@ -2571,7 +2571,8 @@
"chapter-count": "{{common.chapter-count}}",
"releasing": "Lançando",
"details": "Exibir página",
"updating-metadata-status": "Atualizando Metadados"
"updating-metadata-status": "Atualizando Metadados",
"issue-count": "{{common.issue-count}}"
},
"email-history": {
"description": "Aqui você encontra todos os e-mails enviados por Kavita e para qual usuário.",
@ -2622,7 +2623,18 @@
"enable-cover-image-tooltip": "Permitir que Kavita escreva a imagem da capa das Séries",
"localized-name-tooltip": "Permitir que o nome localizado seja escrito quando o campo for desbloqueado. Kavita tentará dar o melhor palpite.",
"overrides-description": "Permita que Kavita escreva os campos trancados.",
"overrides-label": "Substituir"
"overrides-label": "Substituir",
"enable-chapter-title-label": "Título",
"enable-chapter-publisher-label": "Editora",
"enable-chapter-publisher-tooltip": "Permitir que a Editora do Capítulo/Número seja salvo",
"enable-chapter-cover-label": "Capa do Capítulo",
"enable-chapter-cover-tooltip": "Permitir que a Capa do Capítulo/Número seja definida",
"chapter-header": "Campos de Capítulo",
"enable-chapter-title-tooltip": "Permitir que o Título do Capítulo/Número seja salvo",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "Data de Lançamento",
"enable-chapter-release-date-tooltip": "Permitir que a Data de Lançamento do Capítulo/Número seja salvo"
},
"metadata-setting-field-pipe": {
"publication-status": "{{edit-series-modal.publication-status-title}}",
@ -2633,7 +2645,12 @@
"localized-name": "{{edit-series-modal.localized-name-label}}",
"age-rating": "{{metadata-fields.age-rating-title}}",
"people": "{{tabs.people-tab}}",
"summary": "{{filter-field-pipe.summary}}"
"summary": "{{filter-field-pipe.summary}}",
"chapter-summary": "Resumo (Capítulo)",
"chapter-covers": "Capas (Capítulo)",
"chapter-publisher": "{{person-role-pipe.publisher}} (Capítulo)",
"chapter-title": "Título (Capítulo)",
"chapter-release-date": "Data de Lançamento (Capítulo)"
},
"role-localized-pipe": {
"admin": "Admin",
@ -2651,5 +2668,27 @@
"trace": "Rastro",
"warning": "Aviso",
"critical": "Crítico"
},
"reviews": {
"user-reviews-local": "Análises Locais",
"user-reviews-plus": "Análises Externas"
},
"review-modal": {
"title": "Editar Análise",
"review-label": "Análise",
"close": "{{common.close}}",
"delete": "{{common.delete}}",
"min-length": "A análise deve ter pelo menos {{count}} caracteres",
"required": "{{validation.required-field}}",
"save": "{{common.save}}"
},
"merge-person-modal": {
"save": "{{common.save}}",
"src": "Mesclar Pessoa",
"alias-title": "Novos pseudônimos",
"close": "{{common.close}}",
"title": "{{personName}}",
"merge-warning": "se prosseguir, a pessoa selecionada será removida. O nome da pessoa selecionada será adicionado como um pseudônimo e todas as suas funções serão transferidas.",
"known-for-title": "Conhecido por"
}
}

View file

@ -59,15 +59,6 @@
"spoiler": {
"click-to-show": "Спойлер, нажмите, чтобы показать"
},
"review-series-modal": {
"title": "Редактировать отзыв",
"review-label": "Обзор",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "Отзыв должен содержать не меньше {{count}} символов",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Обзор на {{username}}",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "Spojler, kliknite na zobrazenie"
},
"review-series-modal": {
"title": "Upraviť recenziu",
"review-label": "Recenzia",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}",
"min-length": "Recenzia musí obsahovať aspoň {{count}} znakov"
},
"review-card-modal": {
"close": "{{common.close}}",
"external-mod": "(externé)",
@ -784,8 +775,6 @@
"more-alt": "Viac",
"publication-status-title": "Publikácia",
"publication-status-tooltip": "Stav publikácie",
"user-reviews-local": "Miestne recenzie",
"user-reviews-plus": "Externé recenzie",
"pages-count": "{{num}} Strán",
"words-count": "{{num}} Slov",
"weblinks-title": "Odkazy",

View file

@ -3,7 +3,7 @@
"username": "{{common.username}}",
"password": "{{common.password}}",
"password-validation": "{{validation.password-validation}}",
"title": "Logga in till ditt konto",
"title": "Logga in ditt konto",
"forgot-password": "Glömt lösenord?",
"submit": "Logga in"
},
@ -43,15 +43,6 @@
"token-expired": "Din AniList-token är Utgången! Scrobbling kommer inte processeras förrän du har förnyat det under Konto.",
"generate-scrobble-events": "Backfill Events"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}",
"title": "Redigera recension",
"review-label": "Recension",
"min-length": "Recension måste vara minst {{count}} tecken"
},
"review-card-modal": {
"close": "{{common.close}}",
"external-mod": "(extern)",
@ -86,7 +77,7 @@
"locale-label": "Språk",
"locale-tooltip": "Språket Kavita ska nyttja",
"blur-unread-summaries-label": "Censurera olästa sammanfattningar",
"blur-unread-summaries-tooltip": "Cencurera sammanfattningar på volymer eller kapitel som inte har någon läsprogress (för att undvika spoilers)",
"blur-unread-summaries-tooltip": "Cencurera handlingen på volymer eller kapitel som inte har någon läsprogress (för att undvika spoilers)",
"disable-animations-tooltip": "Inaktivera animationer på sidan. Användbart på e-ink-läsare.",
"reading-mode-label": "Läsläge",
"layout-mode-label": "Layoutläge",
@ -269,7 +260,7 @@
"age-restriction-label": "Åldersbegränsning"
},
"scrobbling-providers": {
"instructions": "First time users should click on \"{{scrobbling-providers.generate}}\" below to allow Kavita+ to talk with {{service}}. Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.",
"instructions": "Nya användare bör klicka på \"{{scrobbling-providers.generate}}\" nedan för att tillåta Kavita+ att prata med {{service}}. Efter att du gett programmet åtkomst, kopiera och klistra in token i rutan nedanför. Du kan generera nytt token när som helst.",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
@ -504,7 +495,7 @@
"incognito-mode-label": "Inkognitoläge",
"next": "Nästa",
"next-chapter": "Nästa Kapitel/Volym",
"close-reader": "Stäng läsare",
"close-reader": "Stäng Läsare",
"go-to-page": "Gå till sida",
"go-to-last-page": "Gå till sista sidan",
"prev-chapter": "Förra Kapitel/Volym",
@ -601,8 +592,6 @@
"cover-change": "Det kan ta upp till en minut för att din webbläsare ska uppdatera bilden. Under tiden kan den gamla bilden visas på vissa sidor.",
"layout-mode-option-list": "Lista",
"read-incognito": "Läs inkognito",
"user-reviews-plus": "Externa recensioner",
"user-reviews-local": "Lokala recensioner",
"continue-incognito": "Fortsätt inkognito",
"incognito": "Inkognito",
"continue-from": "Fortsätt {{title}}",
@ -636,7 +625,7 @@
"editors-title": "Redaktörer",
"colorists-title": "Färgläggare",
"letterers-title": "Bokstavsättare",
"inkers-title": "Inker"
"inkers-title": "Inkers"
},
"external-rating": {
"close": "{{common.close}}",
@ -944,7 +933,7 @@
"bookmark-dir-tooltip": "Plats där bokmärken kommer att lagras. Bokmärken är källfiler och kan vara stora. Välj en plats med tillräckligt med lagring. Katalogen hanteras; andra filer inom katalogen kommer att raderas. Om du använder Docker, montera en extra volym och använd den.",
"encode-as-description-part-2": "Kan jag använda WebP?",
"media-issue-title": "Medieproblem",
"scrobble-issue-title": "Scrobble-problem",
"scrobble-issue-title": "Scrobble Nummer",
"cover-image-size-label": "Omslagsbildsstorlek"
},
"manage-scrobble-errors": {
@ -1110,7 +1099,7 @@
"filter-label": "{{common.filter}}",
"promote-tooltip": "Marknadsför betyder att taggen kan ses over hela servern, inte bara för admin-användare. Alla serier som har denna tagg kommer fortfarande att ha användarbegränsningar placerade på sig.",
"title": "Redigera {{collectionName}} Samling",
"summary-label": "Summering",
"summary-label": "Handling",
"missing-series-title": "Saknade Serier:",
"name-validation": "Namn måste vara unikt",
"name-label": "Namn",
@ -1295,7 +1284,7 @@
"close": "{{common.close}}",
"save": "{{common.save}}",
"required-field": "{{validation.required-field}}",
"summary-label": "Summering",
"summary-label": "Handling",
"promote-label": "Tipsa",
"promote-tooltip": "Tipsa betyder att samlingen kan ses över hela servern, inte bara av dig. Alla serier inom denna samling kommer fortfarande att ha användarbegränsningar som gäller för dem.",
"starting-title": "Startar",
@ -1494,7 +1483,7 @@
"highest-count-tooltip": "Högsta Antal funnet i alla ComicInfo i serien",
"name-label": "Namn",
"sort-name-label": "Sortera Namn",
"summary-label": "Summering",
"summary-label": "Handling",
"total-words-title": "Totalt antal Ord",
"size-title": "Storlek",
"specials-volume": "Specials",
@ -1667,7 +1656,7 @@
"filter": "{{common.filter}}",
"clear": "{{common.clear}}",
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
"reorder-when-filter-present": "Du kan inte ordna om objekt via drag & släpp medan ett filter används. Använd {{customize-sidenav-streams.order-numbers-label}}",
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}",
"bulk-mode-label": "Massläge",
"no-data-external-source": "Alla Externa Källor har lagts till i Sido Nav eller så har inga skapats än.",
@ -1752,7 +1741,7 @@
"imprint": "Förlag",
"average-rating": "Genomsnittligt betyg",
"series-name": "Serie Namn",
"summary": "Summering",
"summary": "Handling",
"read-date": "Läsdatum",
"want-to-read": "Vill Läsa",
"publication-status": "Publikationsstatus"
@ -1823,13 +1812,15 @@
"remove-from-on-deck-tooltip": "Radera serier från att visas på Hyllan",
"add-to-collection-tooltip": "Lägg till serier i en samling",
"delete-tooltip": "Det finns inget sätt att ångra detta beslut",
"back-to": "Tillbaka till {{action}}"
"back-to": "Tillbaka till {{action}}",
"rename": "Byt namn",
"rename-tooltip": "Byt namn på Smart Filter"
},
"dashboard": {
"recently-added-title": "Nyligen tillagda serier",
"more-in-genre-title": "Mer inom {{genre}}",
"server-settings-link": "Serverinställningar",
"no-libraries": "Det är inga bibliotek skapade än. Skapa några i",
"no-libraries": "Det finns inga bibliotek skapade än. Skapa några i",
"not-granted": "Du har inte fått rättigheter till något bibliotek.",
"on-deck-title": "På hyllan",
"recently-updated-title": "Nyligen uppdaterade serier"
@ -1866,7 +1857,7 @@
},
"manage-metadata-settings": {
"enabled-tooltip": "Tillåt Kavita att ladda ner metadata och skriva till sin databas.",
"summary-label": "Summering",
"summary-label": "Handling",
"enable-people-label": "Personer",
"enable-start-date-tooltip": "Tillåt Seriens startdatum att bli skriven till serien",
"enable-genres-label": "Genres",
@ -1887,7 +1878,7 @@
"enable-genres-tooltip": "Tillåt serie-genrer att skrivas.",
"description": "Kavita+ har möjlighet att ladda ner och skriva viss begränsad metadata till databasen. Denna sida gör det möjligt för dig att växla vad som ingår.",
"enable-people-tooltip": "Tillåt personer (karaktärer, författare, osv.) att <b>läggas till</b>. Alla personer inkluderar bilder.",
"summary-tooltip": "TIllåt Summering att skrivas när fältet är upplåst.",
"summary-tooltip": "Tillåt Handling att skrivas när fältet är upplåst.",
"derive-publication-status-tooltip": "Tillåt publiceringsstatus att härledas från totala kapitel-/volymantal.",
"derive-publication-status-label": "Publiceringsstatus",
"enable-relations-label": "Förhållanden",
@ -1903,7 +1894,18 @@
"enable-tags-label": "Taggar",
"age-rating-mapping-title": "Åldersgruppsbedömning",
"add-age-rating-mapping-label": "Lägg till Åldersgruppsbedömning",
"field-mapping-title": "Fältmappning"
"field-mapping-title": "Fältmappning",
"enable-chapter-title-label": "Titel",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-title-tooltip": "Tillåt Titeln för Kapitel/Nummer att skrivas",
"enable-chapter-cover-tooltip": "Tillåt Omslag för Kapitel/Nummer att ställas in",
"enable-chapter-cover-label": "Kapitelomslag",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "Släppdatum",
"enable-chapter-release-date-tooltip": "Tillåt Släppdatum för Kapitel/Nummer att skrivas",
"enable-chapter-publisher-tooltip": "Tillåt Utgivare för Kapitel/Nummer att skrivas",
"enable-chapter-publisher-label": "Utgivare",
"chapter-header": "Kapitelfält"
},
"match-series-result-item": {
"releasing": "Släpper",
@ -2129,7 +2131,7 @@
"show": "Visa",
"hide": "Göm",
"regen-warning": "Att regenerera din API-nyckel kommer att ogiltigförklara alla befintliga klienter.",
"no-key": "FEL NYCKEL INTE ANGETTS",
"no-key": "ERROR NYCKEL HAR EJ ANGETTS",
"confirm-reset": "Detta kommer att ogiltigförklara alla OPDS-konfigurationer du har ställt in. Är du säker på att du vill fortsätta?",
"key-reset": "Återställ API-nyckel",
"copy": "Kopiera"
@ -2168,7 +2170,7 @@
"sunday": "Söndag"
},
"device-platform-pipe": {
"custom": "Anpassa"
"custom": "Anpassad"
},
"cbl-import-result-pipe": {
"success": "Lyckats",
@ -2648,6 +2650,11 @@
"start-date": "{{manage-metadata-settings.enable-start-date-label}}",
"genres": "{{metadata-fields.genres-title}}",
"tags": "{{metadata-fields.tags-title}}",
"localized-name": "{{edit-series-modal.localized-name-label}}"
"localized-name": "{{edit-series-modal.localized-name-label}}",
"chapter-summary": "Handling (Kapitel)",
"chapter-publisher": "{{person-role-pipe.publisher}} (Kapitel)",
"chapter-title": "Titel (Kapitel)",
"chapter-release-date": "Släppdatum (Kapitel)",
"chapter-covers": "Omslag (Kapitel)"
}
}

View file

@ -75,15 +75,6 @@
"spoiler": {
"click-to-show": "ச்பாய்லர், காட்ட சொடுக்கு செய்க"
},
"review-series-modal": {
"title": "மதிப்பாய்வைத் திருத்தவும்",
"review-label": "சீராய்வு",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "மதிப்பாய்வு குறைந்தபட்சம் {{count}} எழுத்துக்களாக இருக்க வேண்டும்",
"required": "{{validation.required-field}}"
},
"theme-manager": {
"set-default": "இயல்புநிலையை அமைக்கவும்",
"default-theme": "இயல்புநிலை",
@ -237,7 +228,6 @@
"kavita+-requirement": "கவிதா+ அண்மைக் கால வெளியீடு - 2 பதிப்புகளுடன் மட்டுமே வேலை செய்ய வடிவமைக்கப்பட்டுள்ளது. அதற்கு வெளியே எதுவும் வேலை செய்யாததற்கு உட்பட்டது."
},
"series-detail": {
"user-reviews-plus": "வெளிப்புற மதிப்புரைகள்",
"writers-title": "{{metadata-fields.writers-title}}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}}",
"characters-title": "{{metadata-fields.Characters-title}}}",
@ -251,7 +241,6 @@
"tags-title": "{{metadata-fields.tags-title}}}",
"ongoing": "{{வெளியீட்டு-நிலை-பைப்.ங்கோயிங்}}",
"release-date-title": "வெளியீடு",
"user-reviews-local": "உள்ளக மதிப்புரைகள்",
"page-settings-title": "பக்க அமைப்புகள்",
"close": "{{common.close}}",
"layout-mode-label": "{{பயனர்-முன்னுரிமைகள்.லேவுட்-மோட்-புக்-லேபிள்}}}",

View file

@ -55,14 +55,6 @@
"spoiler": {
"click-to-show": "คลิกเพื่อแสดง"
},
"review-series-modal": {
"title": "แก้ไขรีวิว",
"review-label": "รีวิว",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "รีวิวของ {{username}}",

View file

@ -49,14 +49,6 @@
"spoiler": {
"click-to-show": "Spoiler, görmek için tıklayın"
},
"review-series-modal": {
"title": "İncelemeyi düzenle",
"review-label": "İnceleme",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}} İncelemesi",
@ -122,7 +114,9 @@
"clients-api-key-tooltip": "API anahtarı bir şifre gibidir. Sıfırlamak mevcut istemcileri geçersiz kılar.",
"clients-opds-url-tooltip": "Desteklenen OPDS istemcilerinin listesine bakın: ",
"reset": "{{common.reset}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"pdf-reader-settings-title": "PDF okuyucu",
"kavitaplus-settings-title": "Kavita+"
},
"user-holds": {
"no-data": "{{typeahead.no-data}}"
@ -138,7 +132,9 @@
"delete": "{{common.delete}}",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"add": "{{common.add}}"
"add": "{{common.add}}",
"downloadable": "İndirilebilir",
"downloaded": "İndirildi"
},
"theme": {
"theme-dark": "Karanlık",
@ -183,7 +179,10 @@
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
"required-field": "{{validation.required-field}}"
"required-field": "{{validation.required-field}}",
"confirm-password-label": "Parola onayı",
"current-password-label": "Geçerli parola",
"new-password-label": "Yeni parola"
},
"change-email": {
"required-field": "{{validation.required-field}}",
@ -192,7 +191,10 @@
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
"setup-user-account": "Kullanıcının hesabını kur"
"setup-user-account": "Kullanıcının hesabını kur",
"email-title": "E-posta",
"email-label": "Yeni e-posta",
"current-password-label": "Geçerli parola"
},
"change-age-restriction": {
"reset": "{{common.reset}}",
@ -258,12 +260,16 @@
"email-label": "{{common.email}}",
"required-field": "{{validation.required-field}}",
"valid-email": "{{validation.valid-email}}",
"submit": "{{common.submit}}"
"submit": "{{common.submit}}",
"title": "Parolayı sıfırla"
},
"reset-password-modal": {
"close": "{{common.close}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"title": "{{username}}'ın parolasını sıfırla",
"new-password-label": "Yeni parola",
"error-label": "Hata: "
},
"all-series": {
"series-count": "{{common.series-count}}"
@ -295,7 +301,8 @@
"activate-email-label": "{{common.email}}",
"activate-delete": "{{common.delete}}",
"activate-reset": "Lisansı Sıfırla",
"activate-save": "{{common.save}}"
"activate-save": "{{common.save}}",
"reset-label": "Sıfırla"
},
"book-line-overlay": {
"close": "{{common.close}}",
@ -317,7 +324,9 @@
"password-label": "{{common.password}}",
"required-field": "{{validation.required-field}}",
"submit": "{{common.submit}}",
"password-validation": "{{validation.password-validation}}"
"password-validation": "{{validation.password-validation}}",
"description": "Yeni parola girin",
"title": "Parolayı sıfırla"
},
"register": {
"username-label": "{{common.username}}",
@ -362,7 +371,7 @@
"volume-num": "{{common.volume-num}}",
"reading-lists-title": "{{side-nav.reading-lists}}",
"time-to-read-alt": "{{sort-field-pipe.time-to-read}}",
"scrobbling-tooltip": "{{settings.scrobbling}}"
"scrobbling-tooltip": "{{settings.scrobbling}}: {{value}}"
},
"metadata-fields": {
"collections-title": "{{side-nav.collections}}",
@ -393,7 +402,8 @@
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
"required-field": "{{validation.required-field}}",
"help": "{{common.help}}"
"help": "{{common.help}}",
"force-scan": "Taramaya zorla"
},
"reader-settings": {
"font-family-label": "{{user-preferences.font-family-label}}",
@ -403,7 +413,8 @@
"reading-direction-label": "{{user-preferences.reading-direction-book-label}}",
"writing-style-label": "{{user-preferences.writing-style-label}}",
"immersive-mode-label": "{{user-preferences.immersive-mode-label}}",
"layout-mode-label": "{{user-preferences.layout-mode-book-label}}"
"layout-mode-label": "{{user-preferences.layout-mode-book-label}}",
"reset-to-defaults": "Varsayılanlara sıfırla"
},
"bookmarks": {
"title": "{{side-nav.bookmarks}}",
@ -438,7 +449,8 @@
"cover-image-chooser": {
"reset": "{{common.reset}}",
"apply": "{{common.apply}}",
"applied": "{{theme-manager.applied}}"
"applied": "{{theme-manager.applied}}",
"reset-cover-tooltip": "Kapak görselini sıfırla"
},
"edit-series-relation": {
"remove": "{{common.remove}}",
@ -747,7 +759,8 @@
"manage-smart-filters": {
"delete": "{{common.delete}}",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"cancel": "{{common.cancel}}"
},
"edit-external-source-item": {
"save": "{{common.save}}",
@ -777,7 +790,8 @@
"writers": "{{metadata-fields.writers-title}}"
},
"actionable": {
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"scan-library-tooltip": "Değişiklikler için kütüphaneyi tarayın. Her klasörü kontrol etmek için taramayı zorla kullanın"
},
"preferences": {
"1-column": "1 Sütun",
@ -802,18 +816,30 @@
"username": "Kullanıcı adı",
"password": "Parola",
"select-all": "Tümünü Seç",
"book-num": "Kitap"
"book-num": "Kitap",
"cancel": "İptal",
"reset-to-default": "Varsayılan olarak sıfırla"
},
"tabs": {
"account-tab": "Hesap"
},
"toasts": {
"account-registration-complete": "Hesap kaydı tamamlandı",
"account-migration-complete": "Hesap taşıma tamamlandı"
"account-migration-complete": "Hesap taşıma tamamlandı",
"reset-ip-address": "IP adresleri sıfırla",
"password-reset": "Parola sıfırla",
"email-service-reset": "E-posta hizmeti sıfırla"
},
"api-key": {
"hide": "Gizle",
"show": "Göster",
"copy": "Kopyala"
"copy": "Kopyala",
"reset": "Sıfırla"
},
"confirm": {
"cancel": "{{common.cancel}}"
},
"publication-status-pipe": {
"cancelled": "İptal edildi"
}
}

View file

@ -48,15 +48,6 @@
"not-processed": "Не опрацьовано",
"chapter-num": "Розділ"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}",
"title": "Редагувати огляд",
"review-label": "Огляд",
"min-length": "Огляд мусить містити принаймні {{count}} знаків"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "Огляд від {{username}}",

View file

@ -39,15 +39,6 @@
"processed": "Đã Xử Lý",
"not-processed": "Chưa Xử Lý"
},
"review-series-modal": {
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"required": "{{validation.required-field}}",
"title": "Sửa Đánh Giá",
"review-label": "Đánh Giá",
"min-length": "Đánh giá phải dài ít nhất {{count}} ký tự"
},
"review-card-modal": {
"close": "{{common.close}}",
"go-to-review": "Tới Phần Đánh giá",
@ -562,8 +553,6 @@
"read-options-alt": "Tùy Chọn Đọc",
"edit-series-alt": "Chỉnh Sửa Thông Tin",
"send-to": "Tệp được gửi qua email tới {{deviceName}}",
"user-reviews-local": "Đánh Giá Cục Bộ",
"user-reviews-plus": "Đánh Giá Bên Ngoài",
"release-date-title": "Phát hành",
"pages-count": "{{num}} Trang",
"words-count": "{{num}} Từ",

View file

@ -62,15 +62,6 @@
"spoiler": {
"click-to-show": "剧透,点击显示"
},
"review-series-modal": {
"title": "编辑评论",
"review-label": "评论",
"close": "{{common.close}}",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "评论必须至少有 {{count}} 个字符",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
"user-review": "{{username}}的评论",
@ -81,7 +72,8 @@
"your-review": "我的评论",
"external-review": "外部评论",
"local-review": "本地评论",
"rating-percentage": "评分 {{r}}%"
"rating-percentage": "评分 {{r}}%",
"critic": "评论家"
},
"want-to-read": {
"title": "想读",
@ -106,7 +98,7 @@
"locale-label": "本地语言",
"locale-tooltip": "Kavita 当前语言",
"blur-unread-summaries-label": "模糊未读摘要",
"blur-unread-summaries-tooltip": "模糊没有阅读进度的卷或章节的摘要文本(避免剧透)",
"blur-unread-summaries-tooltip": "模糊没有阅读进度的卷或章节的内容简介文本(避免剧透)",
"prompt-on-download-label": "下载时提示",
"prompt-on-download-tooltip": "当下载大小超过 {{size}}MB 时提示",
"disable-animations-label": "关闭动画",
@ -762,8 +754,6 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "本卷没有章节,无法读取。",
"cover-change": "刷新图片需要一点时间。在此期间,可能某些页面仍显示旧图片。",
"user-reviews-local": "本地评论",
"user-reviews-plus": "外部评论",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",
@ -825,7 +815,8 @@
"entry-label": "查看详情",
"kavita-tooltip": "您的评分 + 总体",
"kavita-rating-title": "您的评分",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "{{review-card.critic}}"
},
"badge-expander": {
"more-items": "和{{count}}个更多"
@ -1522,7 +1513,8 @@
"logout": "注销",
"all-filters": "智能筛选",
"nav-link-header": "导航选项",
"close": "{{common.close}}"
"close": "{{common.close}}",
"person-aka-status": "匹配别名"
},
"promoted-icon": {
"promoted": "{{common.promoted}}"
@ -2261,7 +2253,8 @@
"match-success": "系列匹配正确",
"webtoon-override": "由于图片代表网络漫画,因此切换到网络漫画模式。",
"scrobble-gen-init": "将一项任务加入队列,该任务将根据过去的阅读历史和评分生成 scrobble 事件,并将其与已连接的服务同步。",
"confirm-delete-multiple-volumes": "您确定要删除这 {{count}} 个卷吗?这不会修改磁盘上的文件。"
"confirm-delete-multiple-volumes": "您确定要删除这 {{count}} 个卷吗?这不会修改磁盘上的文件。",
"series-added-want-to-read": "从“想读”列表中添加的系列"
},
"read-time-pipe": {
"less-than-hour": "<1 小时",
@ -2336,7 +2329,8 @@
"match-tooltip": "使用 Kavita+ 手动匹配系列",
"reorder": "重新排序",
"rename-tooltip": "重命名智能筛选",
"rename": "重命名"
"rename": "重命名",
"merge": "合并"
},
"preferences": {
"left-to-right": "从左到右",
@ -2485,7 +2479,11 @@
"cover-image-description-extra": "或者,您可以从 CoversDB 下载封面(如果有)。",
"download-coversdb": "从 CoversDB 下载",
"mal-tooltip": "https://myanimelist.net/people/{MalId}/",
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}"
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
"aliases-tooltip": "当某个系列被标记为某个人的别名时,系统会分配该人,而不是创建新的人。删除别名后,您必须重新扫描该系列才能生效。",
"aliases-tab": "别名",
"aliases-label": "编辑别名",
"alias-overlap": "此别名已经指向另一个人或为该人的名字,请考虑合并它们。"
},
"person-detail": {
"all-roles": "角色",
@ -2493,7 +2491,9 @@
"individual-role-title": "作为 {{role}}",
"browse-person-title": "{{name}} 的全部作品",
"browse-person-by-role-title": "{{name}} 作为 {{role}} 的所有作品",
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
"no-info": "没有关于此人的信息",
"aka-title": "又名 "
},
"browse-authors": {
"author-count": "{{num}} 人",
@ -2507,7 +2507,7 @@
"description": "选择一个匹配项来重新连接 Kavita+ 元数据并重新生成 scrobble 事件。 不匹配可用于限制 Kavita 匹配元数据和乱序。",
"no-results": "无法找到匹配项。尝试添加来自受支持的提供商的 URL然后重试。",
"query-label": "询问",
"query-tooltip": "输入系列名称、AniList/MyAnimeList url。 URL 将使用直接查找。",
"query-tooltip": "输入系列名称、AniList/MyAnimeList/ComicBookRoundup 网址。网址将使用直接查找。",
"dont-match-label": "不匹配",
"dont-match-tooltip": "从匹配和 scrobbling 中选择该系列",
"search": "搜索"
@ -2571,7 +2571,8 @@
"chapter-count": "{{common.chapter-count}}",
"releasing": "释放",
"details": "查看页面",
"updating-metadata-status": "更新元数据"
"updating-metadata-status": "更新元数据",
"issue-count": "{{common.issue-count}}"
},
"email-history": {
"description": "在这里您可以找到从 Kavita 发送的所有电子邮件以及发送给哪个用户。",
@ -2622,7 +2623,18 @@
"enable-cover-image-label": "封面图片",
"enable-cover-image-tooltip": "允许 Kavita 为该系列撰写封面图片",
"overrides-label": "覆盖",
"overrides-description": "允许 Kavita 覆盖锁定的字段。"
"overrides-description": "允许 Kavita 覆盖锁定的字段。",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "发布日期",
"enable-chapter-release-date-tooltip": "允许写入章节/期刊的发布日期",
"enable-chapter-cover-label": "章节封面",
"enable-chapter-cover-tooltip": "允许设置章节/期刊的封面",
"chapter-header": "章节字段",
"enable-chapter-publisher-label": "出版社",
"enable-chapter-title-label": "标题",
"enable-chapter-title-tooltip": "允许写入章节/期刊的标题",
"enable-chapter-publisher-tooltip": "允许写入章节/期刊的出版社"
},
"metadata-setting-field-pipe": {
"covers": "封面",
@ -2633,7 +2645,12 @@
"genres": "{{metadata-fields.genres-title}}",
"tags": "{{metadata-fields.tags-title}}",
"localized-name": "{{edit-series-modal.localized-name-label}}",
"publication-status": "{{edit-series-modal.publication-status-title}}"
"publication-status": "{{edit-series-modal.publication-status-title}}",
"chapter-summary": "内容简介(章节)",
"chapter-publisher": "{{person-role-pipe.publisher}} (章节)",
"chapter-title": "标题(章节)",
"chapter-covers": "封面(章节)",
"chapter-release-date": "发布日期(章节)"
},
"role-localized-pipe": {
"download": "下载",
@ -2651,5 +2668,27 @@
"trace": "追踪",
"warning": "警告",
"critical": "严重"
},
"reviews": {
"user-reviews-plus": "外部评审",
"user-reviews-local": "本地评论"
},
"review-modal": {
"review-label": "评论",
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "评论必须至少包含 {{count}} 个字符",
"required": "{{validation.required-field}}",
"title": "编辑评论",
"close": "{{common.close}}"
},
"merge-person-modal": {
"close": "{{common.close}}",
"src": "合并人员",
"title": "{{personName}}",
"known-for-title": "代表作品",
"merge-warning": "如果继续,所选人员将被移除。所选人员的姓名将被添加为别名,并且其所有角色都将被转移。",
"alias-title": "新别名",
"save": "{{common.save}}"
}
}

Some files were not shown because too many files have changed in this diff Show more