mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-06-06 14:55:19 -04:00
Reading List Polish (#4634)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> Co-authored-by: Arden Rasmussen <ardenisthebest@gmail.com>
This commit is contained in:
@@ -120,4 +120,8 @@ export enum Action {
|
||||
* Marks the entity as read while creating a fake reading session
|
||||
*/
|
||||
MarkAsReadWithSession = 37,
|
||||
/**
|
||||
* A special action to just navigate somewhere
|
||||
*/
|
||||
Navigate = 38,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export enum ReadingListFilterField {
|
||||
Tags = 4,
|
||||
Writer = 5,
|
||||
Artist = 6,
|
||||
Provider = 7,
|
||||
MissingItemCount = 8
|
||||
}
|
||||
|
||||
export const allReadingListFilterFields = Object.keys(ReadingListFilterField)
|
||||
|
||||
@@ -62,6 +62,10 @@ export enum ReadingListProvider {
|
||||
Url = 2
|
||||
}
|
||||
|
||||
export const allReadingListProviders = Object.keys(ReadingListProvider)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as ReadingListProvider[];
|
||||
|
||||
export interface ReadingList extends IHasCover {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -66,6 +66,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
|
||||
|
||||
private translateReadingListFilterField(value: ReadingListFilterField) {
|
||||
switch (value) {
|
||||
case ReadingListFilterField.Provider:
|
||||
return translate('generic-filter-field-pipe.readinglist-provider');
|
||||
case ReadingListFilterField.Title:
|
||||
return translate('generic-filter-field-pipe.readinglist-title');
|
||||
case ReadingListFilterField.ReleaseYear:
|
||||
@@ -78,6 +80,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
|
||||
return translate('generic-filter-field-pipe.readinglist-writer');
|
||||
case ReadingListFilterField.Artist:
|
||||
return translate('generic-filter-field-pipe.readinglist-artist');
|
||||
case ReadingListFilterField.MissingItemCount:
|
||||
return translate('generic-filter-field-pipe.readinglist-missing-item-count');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export class ReadingListProviderPipe implements PipeTransform {
|
||||
return this.translocoService.translate('reading-list-provider-pipe.file');
|
||||
case ReadingListProvider.Url:
|
||||
return this.translocoService.translate('reading-list-provider-pipe.url');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import {inject, Pipe, PipeTransform, SecurityContext} from '@angular/core';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
|
||||
@Pipe({
|
||||
name: 'safeHtml',
|
||||
@@ -9,7 +8,6 @@ import { DomSanitizer } from '@angular/platform-browser';
|
||||
})
|
||||
export class SafeHtmlPipe implements PipeTransform {
|
||||
private readonly dom: DomSanitizer = inject(DomSanitizer);
|
||||
constructor() {}
|
||||
|
||||
transform(value: string): string | null {
|
||||
return this.dom.sanitize(SecurityContext.HTML, value);
|
||||
|
||||
@@ -16,8 +16,7 @@ export class UtcToLocalDatePipe implements PipeTransform {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserLanguage = navigator.language;
|
||||
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
|
||||
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal();
|
||||
return dateTime.toJSDate()
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export class ActionFactoryService {
|
||||
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
private sideNavHomeActions: Array<ActionItem<{}>> = [];
|
||||
private sideNavReadingListActions: Array<ActionItem<{}>> = [];
|
||||
private annotationActions: Array<ActionItem<Annotation>> = [];
|
||||
private clientDeviceActions: Array<ActionItem<ClientDevice>> = [];
|
||||
|
||||
@@ -174,6 +175,19 @@ export class ActionFactoryService {
|
||||
);
|
||||
}
|
||||
|
||||
getSideNavReadingListActions(shouldRenderFunc: ActionShouldRenderFunc<{}> = this.basicReadRender) {
|
||||
// If the caller doesn't pass a render function, assume that readonly users cannot perform actions
|
||||
const renderFunc = shouldRenderFunc === this.basicReadRender
|
||||
? (action: ActionItem<any>, entity: any, user: User) => !this.accountService.hasReadOnlyRole()
|
||||
: shouldRenderFunc;
|
||||
|
||||
return this.applyCallbackToList(
|
||||
this.sideNavReadingListActions,
|
||||
(action, entity) => this.actionService.handleSideNavReadingListStream(action, entity),
|
||||
renderFunc
|
||||
);
|
||||
}
|
||||
|
||||
getBulkLibraryActions(shouldRenderFunc: ActionShouldRenderFunc<Library> = this.basicReadRender) {
|
||||
|
||||
const filteredActions = this.flattenActions<Library>(this.libraryActions).filter(a => {
|
||||
@@ -1234,6 +1248,19 @@ export class ActionFactoryService {
|
||||
}
|
||||
];
|
||||
|
||||
this.sideNavReadingListActions = [
|
||||
{
|
||||
action: Action.Navigate,
|
||||
title: 'cbl-manager',
|
||||
description: '',
|
||||
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
];
|
||||
|
||||
this.annotationActions = [
|
||||
{
|
||||
action: Action.Delete,
|
||||
|
||||
@@ -873,6 +873,16 @@ export class ActionService {
|
||||
}
|
||||
}
|
||||
|
||||
handleSideNavReadingListStream(action: ActionItem<{}>, entity: {}) {
|
||||
switch (action.action) {
|
||||
case Action.Navigate:
|
||||
return of(this.fromAction(action, entity, 'none'));
|
||||
|
||||
default:
|
||||
return of(this.fromAction(action, entity, 'none'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized handler for all bulk library actions.
|
||||
* Returns Observable<ActionResult<Library>> so the caller can react to effects.
|
||||
|
||||
@@ -45,13 +45,14 @@ export class CblService {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/re-validate', {fileName});
|
||||
}
|
||||
|
||||
finalizeImport(fileName: string, decisions: CblImportDecisions, provider: ReadingListProvider,
|
||||
finalizeImport(fileName: string, decisions: CblImportDecisions, provider: ReadingListProvider, promote: boolean = false,
|
||||
repoMeta?: { repoPath: string; downloadUrl: string; sha: string }) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/finalize-import', {
|
||||
fileName,
|
||||
decisions,
|
||||
provider,
|
||||
...repoMeta
|
||||
...repoMeta,
|
||||
promote
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class KavitaTitleStrategy extends TitleStrategy {
|
||||
const titleSuffix = route.data['titleSuffix'] || '';
|
||||
const entity = this.findInRouteTree(route, titleField);
|
||||
if (entity?.[titleProp]) {
|
||||
this.title.setTitle(`${entity[titleProp]}${titleSuffix} (Kavita)`);
|
||||
this.title.setTitle(`${entity[titleProp]}${titleSuffix}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import {ReadingListTag} from "../_models/reading-list/reading-list-tag";
|
||||
import {ReadingListSortField} from "../_models/metadata/v2/reading-list-sort-field";
|
||||
import {ReadingListFilterField} from "../_models/metadata/v2/reading-list-filter-field";
|
||||
import {FilterEntityType} from "../_models/metadata/v2/filter-entity-type";
|
||||
import {allReadingListProviders} from "../_models/reading-list/reading-list";
|
||||
import {ReadingListProviderPipe} from "../_pipes/reading-list-provider.pipe";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -64,6 +66,7 @@ export class MetadataService {
|
||||
private ageRatingPipe = new AgeRatingPipe();
|
||||
private mangaFormatPipe = new MangaFormatPipe();
|
||||
private personRolePipe = new PersonRolePipe();
|
||||
private readingListProviderPipe = new ReadingListProviderPipe();
|
||||
|
||||
getSeriesMetadataFromPlus(seriesId: number, libraryType: LibraryType) {
|
||||
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId + '&libraryType=' + libraryType);
|
||||
@@ -393,9 +396,11 @@ export class MetadataService {
|
||||
return {value: tag.id, label: tag.title}
|
||||
})));
|
||||
case ReadingListFilterField.Writer:
|
||||
return this.getPersonOptions(PersonRole.Writer)
|
||||
return this.getPersonOptions(PersonRole.Writer);
|
||||
case ReadingListFilterField.Artist:
|
||||
return this.getPersonOptions(PersonRole.CoverArtist)
|
||||
return this.getPersonOptions(PersonRole.CoverArtist);
|
||||
case ReadingListFilterField.Provider:
|
||||
return of(allReadingListProviders.map(p => { return {value: p, label: this.readingListProviderPipe.transform(p)} }));
|
||||
}
|
||||
|
||||
return of([]);
|
||||
|
||||
@@ -1,176 +1,228 @@
|
||||
<ng-container *transloco="let t; prefix: 'details-tab'">
|
||||
<div class="details pb-3">
|
||||
<div class="details pb-3">
|
||||
|
||||
@let filePathsValue = filePaths();
|
||||
@let filesValue = files();
|
||||
@let filesValue = files();
|
||||
@let filePathsValue = filePaths();
|
||||
@let bm = basicMetadata();
|
||||
|
||||
@if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="header">{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}</h4>
|
||||
<div class="ms-3 d-flex flex-column">
|
||||
@if (filesValue.length > 0) {
|
||||
@for (fp of filesValue; track $index) {
|
||||
{{fp.filePath}}
|
||||
@if (fp.koreaderHash) {
|
||||
({{fp.koreaderHash}})
|
||||
@if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {
|
||||
<section class="mb-3 mt-3 ms-1">
|
||||
<h4 class="kv-section-header">{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}</h4>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
|
||||
@for (fp of filePathsValue; track $index) {
|
||||
<div class="file-card">
|
||||
<span class="file-path">{{fp}}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
@for (fp of filesValue; track fp.id) {
|
||||
<div class="file-card">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<i class="fas fa-file-archive file-card-icon" aria-hidden="true"></i>
|
||||
<span class="file-path">{{fp.filePath}}</span>
|
||||
</div>
|
||||
<div class="file-meta-row mt-2">
|
||||
<span class="file-meta-item">{{t('pages-count', {num: fp.pages | compactNumber})}}</span>
|
||||
<span class="file-meta-item">{{t('bytes-count', {num: fp.bytes | bytes})}}</span>
|
||||
@if (fp.koreaderHash) {
|
||||
<span class="file-meta-item file-meta-hash" [ngbTooltip]="fp.koreaderHash">KOReader ✓</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
@for (fp of filePathsValue; track $index) {
|
||||
{{fp}}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (showBasicMetadata() && bm) {
|
||||
<section class="mb-4 ms-1">
|
||||
<p class="kv-section-header">{{t('basic-metadata-title')}}</p>
|
||||
<div class="label-card-grid">
|
||||
@if (bm.readingTime) {
|
||||
<app-label-card [label]="t('read-time-label')" [value]="bm.readingTime | readTime" />
|
||||
}
|
||||
}
|
||||
<app-label-card [label]="t('pages-label')" [value]="bm.pages != null ? t('pages-count', {num: bm.pages | compactNumber}) : (null | defaultValue)" />
|
||||
<app-label-card [label]="t('words-label')" [value]="bm.words != null ? t('words-count', {num: bm.words | compactNumber}) : (null | defaultValue)" />
|
||||
<app-label-card [label]="t('added-label')" [value]="bm.addedAt | date:'shortDate' | defaultValue" />
|
||||
<app-label-card [label]="t('updated-label')" [value]="(bm.updatedAt ?? null) | timeAgo | defaultValue" />
|
||||
<app-label-card [label]="t('kavita-id-label')" [value]="bm.kavitaId | defaultValue" />
|
||||
@if (bm.sortOrder != null) {
|
||||
<app-label-card [label]="t('sort-order-label')" [value]="bm.sortOrder | defaultValue" />
|
||||
}
|
||||
@if (bm.isSpecial != null) {
|
||||
<app-label-card [label]="t('is-special-label')" [value]="bm.isSpecial ? t('yes-label') : t('no-label')"/>
|
||||
}
|
||||
<app-label-card [label]="t('language-label')" [value]="languageDisplay() | defaultValue" />
|
||||
@if (bm.publicationStatus != null) {
|
||||
<app-label-card [label]="t('pub-status-label')" [value]="bm.publicationStatus | publicationStatus" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<hr class="setting-section-break" aria-hidden="true" />
|
||||
}
|
||||
|
||||
@let metadataEntity = entity();
|
||||
@if (metadataEntity) {
|
||||
<section class="mb-3 ms-1">
|
||||
<h4 class="kv-section-header">{{t('external-metadata-title')}}</h4>
|
||||
<app-external-metadata-detail [entity]="metadataEntity" [isbn]="isbn()" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (webLinks().length > 0) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="kv-section-header">{{t('weblinks-title')}}</h4>
|
||||
<div class="ms-3 pill-row">
|
||||
@for (link of webLinks(); track $index) {
|
||||
<a [href]="link | safeUrl" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" color="primary">
|
||||
<app-image height="16px" width="16px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||
[errorImage]="imageService.errorWebLinkImage" />
|
||||
</app-tag-badge>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@let metadataEntity = entity();
|
||||
@if(metadataEntity) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="header">{{t('external-metadata-title')}}</h4>
|
||||
<div class="ms-3">
|
||||
<app-external-metadata-detail [entity]="metadataEntity" />
|
||||
@if (metadataEntity || webLinks().length > 0) {
|
||||
<hr class="setting-section-break mb-3" aria-hidden="true" />
|
||||
}
|
||||
|
||||
@if (showGenres()) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="kv-section-header">{{t('genres-title')}}</h4>
|
||||
<div class="pill-row">
|
||||
@if (genres().length > 0) {
|
||||
@for (item of genres(); track item.id) {
|
||||
<a href="javascript:void(0)" (click)="openGeneric(FilterField.Genres, item.id)">
|
||||
<app-tag-badge shape="pill" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||
</a>
|
||||
}
|
||||
} @else {
|
||||
<span class="empty-value">{{null | defaultValue}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showGenres()) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="header">{{t('genres-title')}}</h4>
|
||||
<div class="ms-3">
|
||||
<app-badge-expander [includeComma]="true" [items]="genres()" [itemsTillExpander]="3" [defaultExpanded]="true">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Genres, item.id)">{{item.title}}</a>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
@if (showTags()) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="kv-section-header">{{t('tags-title')}}</h4>
|
||||
<div class="pill-row">
|
||||
@if (tags().length > 0) {
|
||||
@for (item of tags(); track item.id) {
|
||||
<a href="javascript:void(0)" (click)="openGeneric(FilterField.Tags, item.id)">
|
||||
<app-tag-badge shape="pill" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||
</a>
|
||||
}
|
||||
} @else {
|
||||
<span class="empty-value">{{null | defaultValue}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasUpperMetadata()) {
|
||||
<div class="setting-section-break" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
<div class="">
|
||||
<app-carousel-reel [items]="metadata().writers" [title]="t('writers-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showTags()) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="header">{{t('tags-title')}}</h4>
|
||||
<div class="ms-3">
|
||||
<app-badge-expander [includeComma]="true" [items]="tags()" [itemsTillExpander]="3" [defaultExpanded]="true">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Tags, item.id)">{{item.title}}</a>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
<div class="">
|
||||
<app-carousel-reel [items]="metadata().colorists" [title]="t('colorists-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().editors" [title]="t('editors-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().coverArtists" [title]="t('cover-artists-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().inkers" [title]="t('inkers-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().letterers" [title]="t('letterers-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().pencillers" [title]="t('pencillers-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().translators" [title]="t('translators-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().characters" [title]="t('characters-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().locations" [title]="t('locations-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().teams" [title]="t('teams-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().imprints" [title]="t('imprints-title')" headerClass="kv-section-header dark-exempt">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" size="medium" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="webLinks()" [title]="t('weblinks-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a class="me-1" [href]="item | safeUrl" target="_blank" rel="noopener noreferrer" [title]="item">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
|
||||
[errorImage]="imageService.errorWebLinkImage" />
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
@if (hasUpperMetadata()) {
|
||||
<div class="setting-section-break" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().writers" [title]="t('writers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().colorists" [title]="t('colorists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().editors" [title]="t('editors-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().coverArtists" [title]="t('cover-artists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().inkers" [title]="t('inkers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().letterers" [title]="t('letterers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().pencillers" [title]="t('pencillers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().translators" [title]="t('translators-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().characters" [title]="t('characters-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().locations" [title]="t('locations-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().teams" [title]="t('teams-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata().imprints" [title]="t('imprints-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" />
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1 +1,65 @@
|
||||
.label-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-sep {
|
||||
border: none;
|
||||
border-top: 1px solid var(--setting-break-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background: var(--label-card-bg);
|
||||
border: 1px solid var(--label-card-border);
|
||||
border-radius: 5px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.file-card-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--label-card-icon-color);
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--file-path-color);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.file-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-meta-item {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.empty-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--label-card-value-muted-color);
|
||||
}
|
||||
|
||||
.file-meta-hash {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core';
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {IHasCast} from "../../_models/common/i-has-cast";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
import {SeriesFilterField} from "../../_models/metadata/v2/series-filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
|
||||
import {MangaFormat} from "../../_models/manga-format";
|
||||
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {MangaFile} from "../../_models/manga-file";
|
||||
import {Series} from "../../_models/series";
|
||||
import {Volume} from "../../_models/volume";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {ChangeDetectionStrategy, Component, computed, effect, inject, input, signal} from '@angular/core';
|
||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import {PersonBadgeComponent} from '../../shared/person-badge/person-badge.component';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {IHasCast} from '../../_models/common/i-has-cast';
|
||||
import {PersonRole} from '../../_models/metadata/person';
|
||||
import {SeriesFilterField} from '../../_models/metadata/v2/series-filter-field';
|
||||
import {FilterComparison} from '../../_models/metadata/v2/filter-comparison';
|
||||
import {FilterUtilitiesService} from '../../shared/_services/filter-utilities.service';
|
||||
import {Genre} from '../../_models/metadata/genre';
|
||||
import {Tag} from '../../_models/tag';
|
||||
import {ImageComponent} from '../../shared/image/image.component';
|
||||
import {ImageService} from '../../_services/image.service';
|
||||
import {MangaFormat} from '../../_models/manga-format';
|
||||
import {SafeUrlPipe} from '../../_pipes/safe-url.pipe';
|
||||
import {AccountService} from '../../_services/account.service';
|
||||
import {MangaFile} from '../../_models/manga-file';
|
||||
import {Series} from '../../_models/series';
|
||||
import {Volume} from '../../_models/volume';
|
||||
import {Chapter} from '../../_models/chapter';
|
||||
import {
|
||||
ExternalMetadataDetailComponent
|
||||
} from "../../shared/_components/external-metadata-detail/external-metadata-detail.component";
|
||||
} from '../../shared/_components/external-metadata-detail/external-metadata-detail.component';
|
||||
import {LabelCardComponent} from '../label-card/label-card.component';
|
||||
import {TagBadgeComponent, TagBadgeCursor} from '../../shared/tag-badge/tag-badge.component';
|
||||
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
||||
import {BytesPipe} from '../../_pipes/bytes.pipe';
|
||||
import {TimeAgoPipe} from '../../_pipes/time-ago.pipe';
|
||||
import {DatePipe} from '@angular/common';
|
||||
import {PublicationStatus} from '../../_models/metadata/publication-status';
|
||||
import {PublicationStatusPipe} from '../../_pipes/publication-status.pipe';
|
||||
import {ReadTimePipe} from '../../_pipes/read-time.pipe';
|
||||
import {IHasReadingTime} from '../../_models/common/i-has-reading-time';
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
|
||||
export interface BasicMetadataInfo {
|
||||
readingTime?: IHasReadingTime | null;
|
||||
pages?: number | null;
|
||||
words?: number | null;
|
||||
addedAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
kavitaId?: number | null;
|
||||
sortOrder?: number | null;
|
||||
isSpecial?: boolean | null;
|
||||
language?: string | null;
|
||||
publicationStatus?: PublicationStatus | null;
|
||||
publicationStatusCurrent?: number | null;
|
||||
publicationStatusTotal?: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-tab',
|
||||
@@ -30,10 +57,18 @@ import {
|
||||
PersonBadgeComponent,
|
||||
TranslocoDirective,
|
||||
ImageComponent,
|
||||
BadgeExpanderComponent,
|
||||
SafeUrlPipe,
|
||||
ExternalMetadataDetailComponent,
|
||||
|
||||
LabelCardComponent,
|
||||
TagBadgeComponent,
|
||||
DefaultValuePipe,
|
||||
BytesPipe,
|
||||
TimeAgoPipe,
|
||||
DatePipe,
|
||||
PublicationStatusPipe,
|
||||
ReadTimePipe,
|
||||
CompactNumberPipe,
|
||||
NgbTooltip,
|
||||
],
|
||||
templateUrl: './details-tab.component.html',
|
||||
styleUrl: './details-tab.component.scss',
|
||||
@@ -43,11 +78,13 @@ export class DetailsTabComponent {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly FilterField = SeriesFilterField;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
metadata = input.required<IHasCast>();
|
||||
entity = input<Series | Volume | Chapter>();
|
||||
@@ -58,14 +95,36 @@ export class DetailsTabComponent {
|
||||
suppressEmptyTags = input<boolean>(false);
|
||||
filePaths = input<string[]>([]);
|
||||
files = input<MangaFile[]>([]);
|
||||
basicMetadata = input<BasicMetadataInfo>();
|
||||
|
||||
hasUpperMetadata = computed(() => {
|
||||
return this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0;
|
||||
});
|
||||
|
||||
showBasicMetadata = computed(() => !!this.basicMetadata());
|
||||
hasUpperMetadata = computed(() => this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0);
|
||||
showTags = computed(() => !this.suppressEmptyTags() || this.tags().length > 0);
|
||||
showGenres = computed(() => !this.suppressEmptyGenres() || this.genres().length > 0);
|
||||
isbn = computed(() => {
|
||||
const entity = this.entity();
|
||||
if (!entity?.hasOwnProperty('isbn')) return null;
|
||||
|
||||
return (this.entity() as Chapter).isbn;
|
||||
});
|
||||
languageName = signal<string | null>(null);
|
||||
languageDisplay = computed(() => {
|
||||
return this.languageName() ?? this.basicMetadata()?.language;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const lang = this.basicMetadata()?.language;
|
||||
const langName = this.languageName();
|
||||
if (lang && !langName) {
|
||||
this.metadataService.getLanguageNameForCode(lang).subscribe(fullCode => {
|
||||
this.languageName.set(fullCode);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
openGeneric(queryParamName: SeriesFilterField, filter: string | number) {
|
||||
if (queryParamName === SeriesFilterField.None) return;
|
||||
|
||||
@@ -252,6 +252,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.chapter.malId = model.malId;
|
||||
this.chapter.hardcoverId = model.hardcoverId;
|
||||
this.chapter.metronId = model.metronId;
|
||||
this.chapter.language = model.language;
|
||||
|
||||
|
||||
const apis = [
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="label-card">
|
||||
<span class="label-card-label">{{label()}}</span>
|
||||
@if (value() != null) {
|
||||
@if (linkUrl()) {
|
||||
<a class="label-card-value" [href]="linkUrl() | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||
{{value()}}<i class="fa-solid fa-external-link ms-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
} @else {
|
||||
<span class="label-card-value">{{value()}}</span>
|
||||
}
|
||||
} @else {
|
||||
<ng-content />
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
.label-card {
|
||||
background: var(--label-card-bg);
|
||||
border: 1px solid var(--label-card-border);
|
||||
border-radius: 5px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.label-card-icon {
|
||||
font-size: 0.65rem;
|
||||
color: var(--label-card-icon-color);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-card-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--label-card-label-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label-card-value {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
|
||||
&:not(a) {
|
||||
color: var(--label-card-value-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
|
||||
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
|
||||
export type LabelCardValueColor = 'default' | 'green' | 'muted';
|
||||
|
||||
@Component({
|
||||
selector: 'app-label-card',
|
||||
templateUrl: './label-card.component.html',
|
||||
styleUrl: './label-card.component.scss',
|
||||
imports: [
|
||||
SafeUrlPipe
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LabelCardComponent {
|
||||
label = input.required<string>();
|
||||
value = input<string | number | null | undefined>();
|
||||
/** When link provided, the value will render as a link **/
|
||||
linkUrl = input<string | undefined>(undefined);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {Annotation} from "../book-reader/_models/annotations/annotation";
|
||||
import {Pagination} from "../_models/pagination";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {map, tap} from "rxjs/operators";
|
||||
import {AnnotationsFilterSettings} from "../metadata-filter/filter-settings";
|
||||
import {AnnotationFilterSettings} from "../metadata-filter/filter-settings";
|
||||
import {
|
||||
AnnotationsFilter,
|
||||
AnnotationsFilterField,
|
||||
@@ -74,7 +74,7 @@ export class AllAnnotationsComponent implements OnInit {
|
||||
filterActive = signal(false);
|
||||
filter = signal<AnnotationsFilter | undefined>(undefined);
|
||||
|
||||
filterSettings: AnnotationsFilterSettings = new AnnotationsFilterSettings();
|
||||
filterSettings: AnnotationFilterSettings = new AnnotationFilterSettings();
|
||||
trackByIdentity = (idx: number, item: Annotation) => `${item.id}`;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
+3
@@ -48,6 +48,9 @@ export class VersionUpdateModalComponent {
|
||||
this.bustLocaleCache();
|
||||
// Refresh manually
|
||||
location.reload();
|
||||
|
||||
// Dismiss anyway in case reload doesn't work
|
||||
this.modal.dismiss();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-7
@@ -211,18 +211,19 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
switchMode(mode: BookLineOverlayMode) {
|
||||
this.mode.set(mode);
|
||||
|
||||
if (mode === BookLineOverlayMode.Bookmark) {
|
||||
this.bookmarkForm.get('name')?.setValue(this.selectedText());
|
||||
this.focusOnBookmarkInput();
|
||||
return;
|
||||
}
|
||||
|
||||
// On mobile, first selection might not match as users can select after the fact. Recalculate
|
||||
const windowText = window.getSelection();
|
||||
const selectedText = windowText?.toString() === '' ? this.selectedText() : windowText?.toString() ?? this.selectedText();
|
||||
|
||||
if (mode === BookLineOverlayMode.Annotate) {
|
||||
if (mode === BookLineOverlayMode.Bookmark) {
|
||||
this.bookmarkForm.get('name')?.setValue(selectedText);
|
||||
this.focusOnBookmarkInput();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (mode === BookLineOverlayMode.Annotate) {
|
||||
const createAnnotation = {
|
||||
id: 0,
|
||||
xPath: this.startXPath,
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
<app-entity-card [entity]="item" [config]="bookmarkConfig()"
|
||||
[index]="position" [maxIndex]="bookmarkEntities().length"
|
||||
(reload)="clearBookmarks(item.data.series)"
|
||||
|
||||
/>
|
||||
</ng-template>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
|
||||
<virtual-scroller [ngClass]="{'empty': items().length === 0 && !isLoading()}" #scroll [items]="items()" [bufferAmount]="bufferAmount" [parentScroll]="parentScroll()!">
|
||||
<div class="grid row g-0" #container id="card-detail-layout-items-container">
|
||||
<div class="grid row g-0" #container id="card-detail-layout-items-container" [style.grid-template-columns]="gridColumnsTemplate()">
|
||||
@for (item of scroll.viewPortItems; track trackItem(i, item); let i = $index) {
|
||||
<div class="card col-auto mt-2 mb-2 card-detail-layout-item"
|
||||
(click)="tryToSaveJumpKey(item)"
|
||||
|
||||
@@ -101,6 +101,7 @@ export class CardDetailLayoutComponent<TFilter extends number, TSort extends num
|
||||
*/
|
||||
customSort = input(false);
|
||||
jumpBarKeys = input<Array<JumpKey>>([]); // This is approx 784 pixels tall, original keys
|
||||
gridColumnsTemplate = input('repeat(auto-fill, 10rem)');
|
||||
|
||||
itemClicked = output<any>();
|
||||
applyFilter = output<FilterEvent>();
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
}
|
||||
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
|
||||
@if (titleLink !== '') {
|
||||
<a [href]="titleLink | safeUrl" class="section-title">{{title}}</a>
|
||||
<a [href]="titleLink | safeUrl" [class]="headerClass()">{{title}}</a>
|
||||
} @else {
|
||||
<a href="javascript:void(0)" class="section-title">{{title}}</a>
|
||||
<a href="javascript:void(0)" [class]="headerClass()">{{title}}</a>
|
||||
}
|
||||
|
||||
@if (iconClasses !== '') {
|
||||
|
||||
@@ -67,6 +67,7 @@ export class CarouselReelComponent {
|
||||
* If using actionables, this is the entity to allow Action.Service to handle logic
|
||||
*/
|
||||
@Input() actionableEntity: ActionableEntity = null;
|
||||
headerClass = input<string>('section-title');
|
||||
readonly sectionClick = output<string>();
|
||||
readonly handleAction = output<ActionItem<any>>();
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
[tags]="chapterValue.tags"
|
||||
[webLinks]="weblinks()"
|
||||
[files]="chapterValue.files"
|
||||
[basicMetadata]="chapterBasicMetadata()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -42,7 +42,7 @@ import {BulkSelectionService} from "../cards/bulk-selection.service";
|
||||
import {ReaderService} from "../_services/reader.service";
|
||||
import {AccountService} from "../_services/account.service";
|
||||
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
||||
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
|
||||
import {BasicMetadataInfo, DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
|
||||
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
|
||||
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
|
||||
import {SeriesFilterField} from "../_models/metadata/v2/series-filter-field";
|
||||
@@ -194,7 +194,22 @@ export class ChapterDetailComponent implements OnInit {
|
||||
return hasAnyCast(chp) || (chp?.genres || []).length > 0 ||
|
||||
(chp?.tags || []).length > 0 || (chp?.webLinks || []).length > 0 || this.accountService.hasAdminRole();
|
||||
})
|
||||
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background');
|
||||
chapterBasicMetadata = computed<BasicMetadataInfo>(() => {
|
||||
const c = this.chapter();
|
||||
return {
|
||||
readingTime: c,
|
||||
pages: c.pages,
|
||||
words: c.wordCount,
|
||||
addedAt: c.createdUtc,
|
||||
updatedAt: c.createdUtc,
|
||||
kavitaId: c.id,
|
||||
sortOrder: c.sortOrder,
|
||||
isSpecial: c.isSpecial,
|
||||
language: c.language || null,
|
||||
publicationStatus: c.publicationStatus ?? null,
|
||||
};
|
||||
});
|
||||
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background')
|
||||
|
||||
activeTabId = Tabs.Details;
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div class="image-container {{imageFitClass$ | async}}"
|
||||
[ngClass]="{'d-none': !renderWithCanvas }"
|
||||
[style.filter]="(darkness$ | async) ?? '' | safeStyle">
|
||||
<canvas #content ondragstart="return false;" onselectstart="return false;" class="{{imageFitClass$ | async}}"></canvas>
|
||||
<canvas #content tabindex="0" [style.outline]="'none'" ondragstart="return false;" onselectstart="return false;" class="{{imageFitClass$ | async}}"></canvas>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
|
||||
readonly imageHeight = output<number>();
|
||||
|
||||
|
||||
readonly canvas = viewChild<ElementRef>('content');
|
||||
readonly canvas = viewChild<ElementRef<HTMLCanvasElement>>('content');
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
|
||||
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
|
||||
@@ -135,7 +135,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
|
||||
ngAfterViewInit() {
|
||||
const canvas = this.canvas();
|
||||
if (canvas) {
|
||||
this.ctx = canvas.nativeElement.getContext('2d', { alpha: false });
|
||||
this.ctx = canvas.nativeElement.getContext('2d', { alpha: false })!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -4,6 +4,7 @@
|
||||
[ngClass]="{'center-double': (shouldRenderDouble$ | async)}">
|
||||
@if (currentImage) {
|
||||
<img alt=" "
|
||||
tabindex="0" [style.outline]="'none'"
|
||||
#image [src]="currentImage.src"
|
||||
id="image-1"
|
||||
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}"
|
||||
|
||||
+12
-1
@@ -1,5 +1,15 @@
|
||||
import { DOCUMENT, NgClass, AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef, ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { Observable, of, map, tap, shareReplay, filter, combineLatest } from 'rxjs';
|
||||
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
|
||||
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
|
||||
@@ -28,6 +38,7 @@ export class DoubleNoCoverRendererComponent implements OnInit {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
readerService = inject(ReaderService);
|
||||
|
||||
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
|
||||
|
||||
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
|
||||
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[ngClass]="{'center-double': (shouldRenderDouble$ | async)}">
|
||||
@if (currentImage) {
|
||||
<img alt=" "
|
||||
tabindex="0" [style.outline]="'none'"
|
||||
#image [src]="currentImage.src"
|
||||
id="image-1"
|
||||
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}"
|
||||
|
||||
+13
-1
@@ -1,5 +1,15 @@
|
||||
import { DOCUMENT, NgClass, AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef, ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { Observable, of, map, tap, shareReplay, filter, combineLatest } from 'rxjs';
|
||||
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
|
||||
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
|
||||
@@ -28,6 +38,8 @@ export class DoubleRendererComponent implements OnInit, ImageRenderer {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
readerService = inject(ReaderService);
|
||||
|
||||
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
|
||||
|
||||
|
||||
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
|
||||
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;
|
||||
|
||||
+1
@@ -5,6 +5,7 @@
|
||||
|
||||
@if(leftImage) {
|
||||
<img alt=" "
|
||||
tabindex="0" [style.outline]="'none'"
|
||||
#image [src]="leftImage.src"
|
||||
id="image-1"
|
||||
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}">
|
||||
|
||||
+12
-2
@@ -1,5 +1,15 @@
|
||||
import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef, ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
output,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import {combineLatest, filter, map, Observable, of, shareReplay, tap} from 'rxjs';
|
||||
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
|
||||
import {ReaderMode} from 'src/app/_models/preferences/reader-mode';
|
||||
@@ -29,7 +39,7 @@ export class DoubleReverseRendererComponent implements OnInit, ImageRenderer {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
readerService = inject(ReaderService);
|
||||
|
||||
|
||||
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
|
||||
|
||||
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
|
||||
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
[scrollContainer]="scrollElement"
|
||||
(triggered)="loadPrevChapter.emit()" />
|
||||
|
||||
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
|
||||
<div #scroller tabindex="0" [style.outline]="'none'" infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
|
||||
@for(item of webtoonImages | async; let index = $index; track item.src) {
|
||||
<img src="{{item.src}}" style="display: block;"
|
||||
[style.filter]="(darkness$ | async) ?? '' | safeStyle"
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
img, .full-width {
|
||||
max-width: 100% !important;
|
||||
height: auto;
|
||||
//height: auto; // This can cause (rarely) a small line between panels
|
||||
}
|
||||
|
||||
// This is to force hardware acceleration to help address https://github.com/Kareadita/Kavita/issues/1848
|
||||
|
||||
+15
-8
@@ -1,5 +1,6 @@
|
||||
import {AsyncPipe, DOCUMENT} from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
output,
|
||||
Renderer2,
|
||||
Signal,
|
||||
SimpleChanges
|
||||
SimpleChanges, viewChild
|
||||
} from '@angular/core';
|
||||
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, Subject, tap} from 'rxjs';
|
||||
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
|
||||
@@ -86,7 +87,7 @@ const enum DEBUG_MODES {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollDirective, SafeStylePipe, PullToLoadComponent]
|
||||
})
|
||||
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
|
||||
private readonly document = inject<Document>(DOCUMENT);
|
||||
private readonly mangaReaderService = inject(MangaReaderService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
@@ -96,6 +97,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly breakpointService = inject(BreakpointService);
|
||||
|
||||
scrollContainer = viewChild.required<ElementRef<HTMLDivElement>>('scroller');
|
||||
|
||||
get scrollElement(): HTMLElement {
|
||||
return this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
|
||||
}
|
||||
@@ -248,6 +251,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.scrollContainer().nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for binding the scroll handler to the correct event. On non-fullscreen, body is correct. However, on fullscreen, we must use the reader as that is what
|
||||
* gets promoted to fullscreen.
|
||||
@@ -257,8 +264,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Reset any modal-induced overflow lock (this can happen when Starting Over and ngBootstrap modal hasn't completed teardown)
|
||||
if (element === this.document.body) {
|
||||
this.document.body.style.overflow = 'auto';
|
||||
this.document.body.classList.remove('modal-open'); // ngBootstrap adds this
|
||||
setTimeout(() => {
|
||||
this.document.body.style.overflow = 'auto';
|
||||
this.document.body.classList.remove('modal-open'); // ngBootstrap adds this
|
||||
}, 100);
|
||||
}
|
||||
|
||||
fromEvent(element, 'scroll')
|
||||
@@ -296,8 +305,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
);
|
||||
|
||||
// We need the injector as toSignal is only allowed in injection context
|
||||
// https://angular.dev/guide/signals#injection-context
|
||||
this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
|
||||
|
||||
// Automatically updates when the breakpoint changes, or when reader settings changes
|
||||
@@ -311,9 +318,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return (parseInt(value) <= 0) ? '' : value + '%';
|
||||
});
|
||||
|
||||
//perform jump so the page stays in view
|
||||
// perform jump so the page stays in view
|
||||
effect(() => {
|
||||
const width = this.widthOverride(); // needs to be at the top for effect to work
|
||||
const width = this.widthOverride();
|
||||
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
|
||||
if(!this.currentPageElem)
|
||||
return;
|
||||
|
||||
@@ -145,6 +145,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly doubleReverseRenderer = viewChild(DoubleReverseRendererComponent);
|
||||
readonly doubleNoCoverRenderer = viewChild(DoubleNoCoverRendererComponent);
|
||||
|
||||
readonly imageElement = computed(() =>
|
||||
this.singleRenderer()?.imageElement()
|
||||
?? this.doubleRenderer()?.imageElement()
|
||||
?? this.doubleReverseRenderer()?.imageElement()
|
||||
?? this.doubleNoCoverRenderer()?.imageElement()
|
||||
?? this.canvasRenderer()?.canvas());
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
@@ -629,6 +636,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
})
|
||||
})
|
||||
).subscribe();
|
||||
|
||||
this.currentImage$.pipe(
|
||||
filter(() => this.readerMode !== ReaderMode.Webtoon),
|
||||
filter(img => !!img),
|
||||
tap(() => {
|
||||
this.imageElement()?.nativeElement?.focus();
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle">
|
||||
@if(currentImage) {
|
||||
<img alt=" "
|
||||
tabindex="0" [style.outline]="'none'"
|
||||
[style.width]="widthOverride()"
|
||||
#image
|
||||
[src]="currentImage.src"
|
||||
|
||||
+3
-1
@@ -10,7 +10,7 @@ import {
|
||||
Input,
|
||||
OnInit,
|
||||
Signal,
|
||||
output
|
||||
output, viewChild, ElementRef, effect
|
||||
} from '@angular/core';
|
||||
import {combineLatest, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs';
|
||||
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
|
||||
@@ -46,6 +46,8 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
|
||||
@Input({required: true}) showClickOverlay$!: Observable<boolean>;
|
||||
@Input({required: true}) pageNum$!: Observable<{pageNum: number, maxPages: number}>;
|
||||
|
||||
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
|
||||
|
||||
readonly imageHeight = output<number>();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
+3
-2
@@ -296,7 +296,7 @@ export class MetadataFilterRowComponent<TFilter extends number = number, TSort e
|
||||
const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains<TFilter>(this.entityType());
|
||||
const customComparisons = this.filterUtilitiesService.getCustomComparisons(this.entityType(), inputVal);
|
||||
|
||||
let baseComparisons: FilterComparison[];
|
||||
let baseComparisons: FilterComparison[] = [];
|
||||
let predicateType: PredicateType;
|
||||
let defaultValue: string | number | boolean;
|
||||
|
||||
@@ -329,13 +329,14 @@ export class MetadataFilterRowComponent<TFilter extends number = number, TSort e
|
||||
}
|
||||
predicateType = PredicateType.Dropdown;
|
||||
defaultValue = 0;
|
||||
} else {
|
||||
} else{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) baseComparisons.push(FilterComparison.IsEmpty);
|
||||
if (fieldsThatShouldIncludeIsNotEmpty.includes(inputVal)) baseComparisons.push(FilterComparison.IsNotEmpty);
|
||||
|
||||
// Custom comparisons need to also be included in some base type to drive the fields
|
||||
const comps = (customComparisons?.length ?? 0) > 0 ? customComparisons : baseComparisons;
|
||||
|
||||
this.validComparisons$.next([...new Set(comps)]);
|
||||
|
||||
@@ -39,7 +39,7 @@ export class PersonFilterSettings extends FilterSettingsBase<PersonFilterField,
|
||||
type: ValidFilterEntity = 'person';
|
||||
}
|
||||
|
||||
export class AnnotationsFilterSettings extends FilterSettingsBase<AnnotationsFilterField, AnnotationsSortField> {
|
||||
export class AnnotationFilterSettings extends FilterSettingsBase<AnnotationsFilterField, AnnotationsSortField> {
|
||||
type : ValidFilterEntity = 'annotation';
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -132,9 +132,9 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
const startMonth = rl.startingMonth > 0 ? rl.startingMonth - 1 : undefined;
|
||||
const endMonth = rl.startingMonth > 0 ? rl.endingMonth - 1 : undefined;
|
||||
|
||||
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear);
|
||||
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear, 0);
|
||||
const endDate = rl.endingYear <= 0 ? null :
|
||||
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear));
|
||||
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear, 0));
|
||||
|
||||
return this.dateYearRangePipe.transform(startDate, endDate, !!endMonth);
|
||||
});
|
||||
|
||||
+1
-3
@@ -77,9 +77,7 @@
|
||||
|
||||
<!-- Summary with blur and fade -->
|
||||
@if (summary()) {
|
||||
<div class="summary-text" [appBlurToggle]="shouldBlur()" [appBlurToggleEnabled]="blurEnabled()">
|
||||
{{summary()}}
|
||||
</div>
|
||||
<div class="summary-text" [appBlurToggle]="shouldBlur()" [appBlurToggleEnabled]="blurEnabled()" [innerHTML]="summary()"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
+3
-6
@@ -11,6 +11,7 @@ import {BlurToggleDirective} from "../../../_directives/blur-toggle.directive";
|
||||
import {LooseLeafOrDefaultNumber} from "../../../_models/chapter";
|
||||
import {DateYearRangePipe, NULL_DATE} from "../../../_pipes/date-year-range.pipe";
|
||||
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-list-item',
|
||||
@@ -23,6 +24,7 @@ export class ReadingListItemComponent {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly safeHtmlPipe = new SafeHtmlPipe();
|
||||
|
||||
item = input.required<ReadingListItem>();
|
||||
position = input(0);
|
||||
@@ -43,18 +45,13 @@ export class ReadingListItemComponent {
|
||||
return translate('common.issue-num-shorthand', {num: chNum})
|
||||
});
|
||||
releaseDate = computed(() => this.item().chapter?.releaseDate || this.item().releaseDate);
|
||||
summary = computed(() => this.item().chapter?.summary || this.item().summary);
|
||||
summary = computed(() => this.safeHtmlPipe.transform(this.item().chapter?.summary ?? this.item().summary ?? ''));
|
||||
pages = computed(() => this.item().chapter?.pages ?? this.item().pagesTotal);
|
||||
writerName = computed(() => this.item().chapter?.writerName);
|
||||
pencillerName = computed(() => this.item().chapter?.pencillerName);
|
||||
|
||||
isUnread = computed(() => this.item().pagesRead === 0 && this.pages() > 0);
|
||||
isInProgress = computed(() => this.item().pagesRead > 0 && this.item().pagesRead < this.pages());
|
||||
progressPercent = computed(() => {
|
||||
const total = this.pages();
|
||||
if (total === 0) return 0;
|
||||
return Math.round((this.item().pagesRead / total) * 100);
|
||||
});
|
||||
|
||||
blurEnabled = computed(() => !!this.accountService.userPreferences()?.blurUnreadSummaries);
|
||||
shouldBlur = computed(() => this.blurEnabled() && this.isUnread());
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
[gridColumnsTemplate]="'repeat(auto-fill, minmax(30rem, 1fr))'"
|
||||
[isLoading]="isLoadingLists()"
|
||||
[items]="listEntities()"
|
||||
[pagination]="pagination()!"
|
||||
@@ -33,13 +34,6 @@
|
||||
<app-reading-list [entity]="item" [index]="position" [maxIndex]="listEntities().length" [config]="readingListConfig()"
|
||||
(reload)="loadPage()" (dataChanged)="updateReadingList($event)" />
|
||||
</div>
|
||||
|
||||
<!-- <app-entity-card [entity]="item" [index]="position" [maxIndex]="listEntities().length" [config]="readingListConfig()"-->
|
||||
<!-- (reload)="loadPage()" (dataChanged)="updateReadingList($event)">-->
|
||||
<!-- <ng-template #title let-entity>-->
|
||||
<!-- <app-promoted-icon [promoted]="entity.data.promoted" /> {{entity.data.title}}-->
|
||||
<!-- </ng-template>-->
|
||||
<!-- </app-entity-card>-->
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
}
|
||||
|
||||
::ng-deep #card-detail-layout-items-container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)) !important;
|
||||
|
||||
.card-detail-layout-item {
|
||||
background-color: transparent !important;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -124,8 +124,11 @@ export class ReadingListsComponent implements OnInit {
|
||||
updateReadingList(updatedEntity: ReadingList) {
|
||||
const originalEntity = this.lists().find(s => s.id == updatedEntity.id);
|
||||
if (originalEntity) {
|
||||
Object.assign(originalEntity, updatedEntity);
|
||||
this.lists.set([...this.lists()]);
|
||||
this.lists.update(l => [...l.map(item => {
|
||||
if (item.id == updatedEntity.id) return updatedEntity;
|
||||
|
||||
return item;
|
||||
})]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
<ng-container *transloco="let t; prefix: 'series-detail'">
|
||||
<app-bulk-operations [marginLeft]="12" [marginRight]="0" />
|
||||
|
||||
@@ -37,7 +35,7 @@
|
||||
</span>
|
||||
|
||||
</h4>
|
||||
<div class="subtitle mt-2 mb-2">
|
||||
<div class="subtitle mt-2 mb-2">
|
||||
@if (seriesValue.localizedName !== seriesValue.name && seriesValue.localizedName) {
|
||||
<span>{{seriesValue.localizedName | defaultValue}}</span>
|
||||
}
|
||||
@@ -367,6 +365,7 @@
|
||||
[tags]="seriesMetadataValue.tags"
|
||||
[webLinks]="weblinksValue"
|
||||
[filePaths]="[seriesValue.folderPath]"
|
||||
[basicMetadata]="seriesBasicMetadata()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -69,7 +69,7 @@ import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-
|
||||
import {MetadataService} from "../../../_services/metadata.service";
|
||||
import {Rating} from "../../../_models/rating";
|
||||
import {ThemeService} from "../../../_services/theme.service";
|
||||
import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component";
|
||||
import {BasicMetadataInfo, DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component";
|
||||
import {ChapterRemovedEvent} from "../../../_models/events/chapter-removed-event";
|
||||
import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component";
|
||||
import {SeriesFilterField} from "../../../_models/metadata/v2/series-filter-field";
|
||||
@@ -239,9 +239,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
|
||||
protected readonly isLoadingReadingHistory = signal(false);
|
||||
protected readonly readingHistoryCurrentPage = signal(1);
|
||||
|
||||
isAdmin = computed(() => {
|
||||
return this.accountService.hasAdminRole();
|
||||
});
|
||||
readonly isAdmin = this.accountService.hasAdminRole;
|
||||
|
||||
activeTabId = Tabs.Storyline;
|
||||
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background');
|
||||
@@ -412,6 +410,21 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
|
||||
return webLinks.split(',');
|
||||
});
|
||||
|
||||
seriesBasicMetadata = computed<BasicMetadataInfo>(() => {
|
||||
const s = this.series();
|
||||
const meta = this.seriesMetadata();
|
||||
return {
|
||||
readingTime: s,
|
||||
pages: s.pages,
|
||||
words: s.wordCount,
|
||||
addedAt: s.created,
|
||||
updatedAt: s.lastChapterAdded,
|
||||
kavitaId: s.id,
|
||||
language: meta?.language || null,
|
||||
publicationStatus: meta?.publicationStatus ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
trackStoryLineIdentity = (index: number, item: StoryLineItem) => item.isChapter ? `${item.chapter!.data.id}_ch_storyline` : `${item.volume!.data.id}_vol_storyline`;
|
||||
|
||||
/**
|
||||
|
||||
+14
-12
@@ -1,15 +1,17 @@
|
||||
<ng-container *transloco="let t; prefix: 'edit-external-metadata-form'">
|
||||
<div class="row">
|
||||
@for(key of metadataIds; track key) {
|
||||
<div class="col-md-6 col-sm-12 mb-3">
|
||||
<span class="fw-bold">{{t(key + '-label')}}</span>
|
||||
@let value = entity()[key];
|
||||
@if (value === 0) {
|
||||
<div>{{null | defaultValue}}</div>
|
||||
} @else {
|
||||
<div>{{entity()[key] | defaultValue}}</div>
|
||||
}
|
||||
<div class="row g-2">
|
||||
@for(item of metadata(); track item.key) {
|
||||
<div class="col-auto mb-3">
|
||||
<app-label-card
|
||||
[label]="t(item.key + '-label')"
|
||||
[value]="item.value | defaultValue:t('not-set-label')"
|
||||
[linkUrl]="item.linkUrl ?? undefined" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-auto mb-3">
|
||||
<app-label-card
|
||||
[label]="t('isbn-label')"
|
||||
[value]="isbn() | defaultValue:t('not-set-label')" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
+26
-3
@@ -1,14 +1,25 @@
|
||||
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {IHasMetadataIds} from "../../../_models/common/i-has-metadata-ids";
|
||||
import {HAS_METADATA_DEFAULTS} from "../edit-external-metadata-form/edit-external-metadata-form.component";
|
||||
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
||||
import {LabelCardComponent} from "../../../_single-module/label-card/label-card.component";
|
||||
|
||||
const URLS = {
|
||||
aniListId: 'https://anilist.co/manga/{id}/',
|
||||
malId: 'https://myanimelist.net/manga/{id}/',
|
||||
mangaBakaId: 'https://mangabaka.org/{id}',
|
||||
hardcoverId: null,
|
||||
comicVineId: null,
|
||||
metronId: null,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-metadata-detail',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
DefaultValuePipe
|
||||
DefaultValuePipe,
|
||||
LabelCardComponent
|
||||
],
|
||||
templateUrl: './external-metadata-detail.component.html',
|
||||
styleUrl: './external-metadata-detail.component.scss',
|
||||
@@ -17,6 +28,18 @@ import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
||||
export class ExternalMetadataDetailComponent {
|
||||
|
||||
entity = input.required<IHasMetadataIds>();
|
||||
protected readonly metadataIds = Object.keys(HAS_METADATA_DEFAULTS) as (keyof IHasMetadataIds)[];
|
||||
/** Extra id to show in this section for details-tab */
|
||||
isbn = input<string | null>(null);
|
||||
|
||||
metadata = computed(() => {
|
||||
const e = this.entity();
|
||||
return (Object.keys(HAS_METADATA_DEFAULTS) as (keyof IHasMetadataIds)[]).map(key => {
|
||||
const rawValue = e[key];
|
||||
const value = rawValue === 0 || rawValue == null ? null : rawValue;
|
||||
const urlTemplate = URLS[key];
|
||||
const linkUrl = urlTemplate && value != null ? urlTemplate.replace('{id}', String(value)) : null;
|
||||
|
||||
return { key, value, linkUrl };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#container
|
||||
class="container"
|
||||
[style.height]="containerHeight()"
|
||||
[style.margin-top]="containerMarginTop()"
|
||||
[class.armed]="isArmed()"
|
||||
[class.triggered]="isTriggered()"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {BreakpointService} from '../../../_services/breakpoint.service';
|
||||
import {isSafari} from "../../../_helpers/browser";
|
||||
|
||||
/** How long (ms) the user must be idle at the scroll boundary before scroll-driven progress arms. */
|
||||
const SCROLL_ARM_DELAY_MS = 100;
|
||||
@@ -78,6 +79,13 @@ export class PullToLoadComponent {
|
||||
readonly containerHeight = computed(() =>
|
||||
this.isArmed() || this.isTriggered() ? `${this.armedHeightRem()}rem` : `${RESTING_HEIGHT_REM}rem`
|
||||
);
|
||||
|
||||
readonly containerMarginTop = computed(() =>
|
||||
this.isArmed() && this.direction() === 'down' && isSafari
|
||||
? `-${this.armedHeightRem() - RESTING_HEIGHT_REM}rem`
|
||||
: '0px'
|
||||
);
|
||||
|
||||
readonly directionArrow = computed(() => {
|
||||
switch (this.direction()) {
|
||||
case 'down': return 'up';
|
||||
@@ -261,6 +269,12 @@ export class PullToLoadComponent {
|
||||
* The guard lasts two animation frames to cover the scroll event dispatch.
|
||||
*/
|
||||
private adjustScrollTop(deltaPx: number) {
|
||||
// We do not need to adjust scroll top on iOS & iPadOS. It's handled by negative margins
|
||||
// It doesn't work anyway. Thanks, Tim Apple
|
||||
if (this.direction() === 'down' && isSafari) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCompensatingScroll = true;
|
||||
|
||||
const scrollEl = this.resolveScrollElement();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {of, switchMap} from "rxjs";
|
||||
import {allPersonFilterFields, PersonFilterField} from "../../_models/metadata/v2/person-filter-field";
|
||||
import {allPersonSortFields} from "../../_models/metadata/v2/person-sort-field";
|
||||
import {
|
||||
AnnotationFilterSettings,
|
||||
FilterSettingsBase,
|
||||
PersonFilterSettings,
|
||||
ReadingListFilterSettings,
|
||||
@@ -260,7 +261,7 @@ export class FilterUtilitiesService {
|
||||
] as unknown as T[];
|
||||
case 'readinglist':
|
||||
return [
|
||||
ReadingListFilterField.Writer, ReadingListFilterField.Artist, ReadingListFilterField.Tags
|
||||
ReadingListFilterField.Writer, ReadingListFilterField.Artist, ReadingListFilterField.Tags, ReadingListFilterField.Provider
|
||||
] as unknown as T[];
|
||||
}
|
||||
}
|
||||
@@ -307,7 +308,7 @@ export class FilterUtilitiesService {
|
||||
] as unknown as T[];
|
||||
case 'readinglist':
|
||||
return [
|
||||
ReadingListFilterField.ItemCount
|
||||
ReadingListFilterField.ItemCount, ReadingListFilterField.MissingItemCount
|
||||
] as unknown as T[];
|
||||
}
|
||||
}
|
||||
@@ -387,7 +388,7 @@ export class FilterUtilitiesService {
|
||||
case 'person':
|
||||
return [] as unknown as T[];
|
||||
case 'readinglist':
|
||||
return [] as unknown as T[];
|
||||
return [ReadingListFilterField.Provider] as unknown as T[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,17 +433,17 @@ export class FilterUtilitiesService {
|
||||
return this.getFieldsThatShouldIncludeIsEmpty<T>(type);
|
||||
}
|
||||
|
||||
getDefaultSettings(entityType: ValidFilterEntity | 'other' | undefined): FilterSettingsBase<any, any> {
|
||||
if (entityType === 'other' || entityType === undefined) {
|
||||
// It doesn't matter, return series type
|
||||
return new SeriesFilterSettings();
|
||||
getDefaultSettings(entityType: ValidFilterEntity): FilterSettingsBase<any, any> {
|
||||
switch (entityType) {
|
||||
case "series":
|
||||
return new SeriesFilterSettings();
|
||||
case "person":
|
||||
return new PersonFilterSettings();
|
||||
case "annotation":
|
||||
return new AnnotationFilterSettings();
|
||||
case "readinglist":
|
||||
return new ReadingListFilterSettings();
|
||||
}
|
||||
|
||||
if (entityType == 'series') return new SeriesFilterSettings();
|
||||
if (entityType == 'person') return new PersonFilterSettings();
|
||||
if (entityType == 'readinglist') return new ReadingListFilterSettings();
|
||||
|
||||
return new SeriesFilterSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,6 +459,15 @@ export class FilterUtilitiesService {
|
||||
FilterComparison.LessThan, FilterComparison.LessThanEqual
|
||||
]
|
||||
}
|
||||
break;
|
||||
case 'readinglist':
|
||||
switch (field) {
|
||||
case ReadingListFilterField.Provider:
|
||||
return [
|
||||
FilterComparison.Equal, FilterComparison.NotEqual
|
||||
]
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
:host {
|
||||
--person-badge-size: 6rem;
|
||||
}
|
||||
|
||||
.tagbadge {
|
||||
background-color: var(--tagbadge-bg-color);
|
||||
transition: all .3s ease-out;
|
||||
@@ -6,23 +10,22 @@
|
||||
font-size: .8rem;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
width: 6rem;
|
||||
word-break: break-word;
|
||||
|
||||
i {
|
||||
max-height: 3rem;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
font-size: 2.96rem;
|
||||
max-height: calc((var(--person-badge-size) / 2));
|
||||
height: calc((var(--person-badge-size) / 2));
|
||||
width: calc((var(--person-badge-size) / 2));
|
||||
font-size: calc((var(--person-badge-size) / 2));
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
background: var(--card-bg-color);
|
||||
max-height: 6rem;
|
||||
height: 6rem;
|
||||
width: 6rem;
|
||||
max-height: var(--person-badge-size);
|
||||
height: var(--person-badge-size);
|
||||
width: var(--person-badge-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {RouterLink} from "@angular/router";
|
||||
imports: [ImageComponent, RouterLink],
|
||||
templateUrl: './person-badge.component.html',
|
||||
styleUrls: ['./person-badge.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { '[style.--person-badge-size]': 'badgeSize()' }
|
||||
})
|
||||
export class PersonBadgeComponent {
|
||||
|
||||
@@ -19,9 +20,15 @@ export class PersonBadgeComponent {
|
||||
|
||||
person = input.required<Person | SeriesStaff>();
|
||||
isStaff = input(false);
|
||||
size = input<'normal' | 'medium' | 'small'>('normal');
|
||||
|
||||
staff = computed(() => this.person() as SeriesStaff);
|
||||
|
||||
badgeSize = computed(() => {
|
||||
const map = { normal: '6rem', medium: '4rem', small: '3rem' };
|
||||
return map[this.size()];
|
||||
});
|
||||
|
||||
hasCoverImage = computed(() => {
|
||||
return this.isStaff() || !!(this.person() as Person).coverImage;
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[class.color-error]="color() === 'error'"
|
||||
[class.selectable-cursor]="selectionMode() === TagBadgeCursor.Selectable"
|
||||
[class.not-allowed-cursor]="selectionMode() === TagBadgeCursor.NotAllowed"
|
||||
[class.clickable-cursor]="selectionMode() === TagBadgeCursor.Clickable">
|
||||
[class.clickable-cursor]="selectionMode() === TagBadgeCursor.Clickable"
|
||||
[class.shape-pill]="shape() === 'pill'">
|
||||
<ng-content />
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.shape-pill {
|
||||
border-radius: 999px;
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--tagbadge-pill-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.outline {
|
||||
border: 1px solid var(--tagbadge-border-color);
|
||||
color: var(--tagbadge-text-color);
|
||||
@@ -85,4 +94,4 @@
|
||||
--tagbadge-border-color: var(--error-color);
|
||||
--tagbadge-text-color: var(--error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class TagBadgeComponent {
|
||||
fillStyle = input<'filled' | 'outline'>('outline');
|
||||
color = input<TagBadgeColor>('default');
|
||||
size = input<'default' | 'sm'>('default');
|
||||
shape = input<'default' | 'pill'>('default');
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
@case (SideNavStreamType.ReadingLists) {
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode"
|
||||
icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/" />
|
||||
icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="readingListActions" labelBy="reading-lists" iconClass="fa-ellipsis-v" (actionHandler)="performReadingListAction($event)" />
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.Collections) {
|
||||
|
||||
@@ -59,6 +59,7 @@ export class SideNavComponent {
|
||||
cachedData: SideNavStream[] | null = null;
|
||||
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions();
|
||||
homeActions: ActionItem<{}>[] = this.actionFactoryService.getSideNavHomeActions();
|
||||
readingListActions: ActionItem<{}>[] = this.actionFactoryService.getSideNavReadingListActions();
|
||||
|
||||
filterQuery: string = '';
|
||||
filterLibrary = (stream: SideNavStream) => {
|
||||
@@ -170,6 +171,12 @@ export class SideNavComponent {
|
||||
this.showMore(true);
|
||||
}
|
||||
}
|
||||
performReadingListAction(event: ActionItem<{}> | ActionResult<{}>) {
|
||||
if (event.action === Action.Navigate) {
|
||||
this.router.navigateByUrl('/settings#cbl-import');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getLibraryTypeIcon(format: LibraryType) {
|
||||
switch (format) {
|
||||
|
||||
@@ -349,7 +349,10 @@ export class TypeaheadComponent implements OnInit {
|
||||
this.typeaheadControl.setValue(this.typeaheadControl.value);
|
||||
this.hasFocus = true;
|
||||
if (this.useOverlay) {
|
||||
this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width;
|
||||
this.triggerWidth = Math.max(
|
||||
this.triggerEl().nativeElement.getBoundingClientRect().width,
|
||||
this.settings.overlayMinWidth ?? 0
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -373,7 +376,10 @@ export class TypeaheadComponent implements OnInit {
|
||||
inputElem.nativeElement.focus();
|
||||
this.hasFocus = true;
|
||||
if (this.useOverlay) {
|
||||
this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width;
|
||||
this.triggerWidth = Math.max(
|
||||
this.triggerEl().nativeElement.getBoundingClientRect().width,
|
||||
this.settings.overlayMinWidth ?? 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ export class TypeaheadSettings<T> {
|
||||
* 'body' renders via CDK overlay attached to the document body, avoiding overflow: hidden clipping.
|
||||
*/
|
||||
dropdownPosition: 'relative' | 'body' = 'relative';
|
||||
/**
|
||||
* Minimum width (px) for the CDK overlay dropdown when dropdownPosition is 'body'.
|
||||
* The overlay will be at least this wide, even if the trigger element is narrower.
|
||||
* Defaults to 0 (overlay matches trigger width exactly).
|
||||
*/
|
||||
overlayMinWidth: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-2
@@ -278,8 +278,21 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary me-2" (click)="dismiss()">{{t('close')}}</button>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (currentSummary()) {
|
||||
<span class="d-inline-flex align-items-center gap-1"
|
||||
[ngbTooltip]="t('promote-tooltip')" container="body">
|
||||
<input class="form-check-input mt-0" type="checkbox"
|
||||
id="promote-checkbox"
|
||||
[checked]="getPromoteForFile(currentFile().fileName)"
|
||||
[disabled]="isCurrentFileUpdate()"
|
||||
(change)="togglePromote(currentFile().fileName)">
|
||||
<label class="form-check-label" for="promote-checkbox">
|
||||
{{t('promote')}}
|
||||
</label>
|
||||
</span>
|
||||
}
|
||||
<button class="btn btn-secondary" (click)="dismiss()">{{t('close')}}</button>
|
||||
<button class="btn btn-primary" [disabled]="isProcessing()" (click)="finalizeAll()">
|
||||
@if (savedFiles().length > 1) {
|
||||
{{t('finalize')}}
|
||||
|
||||
+24
-1
@@ -36,6 +36,7 @@ import {RouterLink} from '@angular/router';
|
||||
import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component';
|
||||
import {modalSaved} from "../../../_models/modal/modal-result";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
|
||||
export interface CblIssueRow {
|
||||
result: CblBookResult;
|
||||
@@ -73,6 +74,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
||||
savedFiles = input.required<CblSavedFile[]>();
|
||||
@@ -82,6 +84,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
currentSummary = signal<CblImportSummary | null>(null);
|
||||
isProcessing = signal(false);
|
||||
remapRules = signal<RemapRule[]>([]);
|
||||
promoteMap = signal<Record<string, boolean>>({});
|
||||
|
||||
/** All rows (matched + issues) for the unified table */
|
||||
allRows = signal<CblIssueRow[]>([]);
|
||||
@@ -111,12 +114,15 @@ export class ImportCblModalComponent implements OnInit {
|
||||
matchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'matched').length);
|
||||
issueCount = computed(() => this.classifiedRows().filter(r => r.category === 'issue').length);
|
||||
unmatchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'unmatched').length);
|
||||
isCurrentFileUpdate = computed(() => this.currentSummary()?.isUpdate ?? false);
|
||||
|
||||
/** Lazy typeahead state, only one row can be resolving at a time */
|
||||
activeRow = signal<CblIssueRow | null>(null);
|
||||
activeSeriesTypeahead = signal<TypeaheadSettings<SearchResult> | null>(null);
|
||||
activeChapterTypeahead = signal<TypeaheadSettings<Chapter> | null>(null);
|
||||
|
||||
defaultPromotionState = this.accountService.hasAdminRole;
|
||||
|
||||
/** Track the CBL series name of the row being resolved, so we can auto-continue after re-validation */
|
||||
private pendingAutoEditSeries: string | null = null;
|
||||
|
||||
@@ -335,6 +341,15 @@ export class ImportCblModalComponent implements OnInit {
|
||||
this.allRows.set([...this.allRows()]);
|
||||
}
|
||||
|
||||
getPromoteForFile(fileName: string): boolean {
|
||||
return this.promoteMap()[fileName] ?? this.defaultPromotionState();
|
||||
}
|
||||
|
||||
togglePromote(fileName: string) {
|
||||
const current = this.promoteMap()[fileName] ?? this.defaultPromotionState();
|
||||
this.promoteMap.update(m => ({ ...m, [fileName]: !current }));
|
||||
}
|
||||
|
||||
toggleRowFilter(category: 'matched' | 'issues' | 'unmatched') {
|
||||
switch (category) {
|
||||
case 'matched': this.showMatched.update(v => !v); break;
|
||||
@@ -382,8 +397,9 @@ export class ImportCblModalComponent implements OnInit {
|
||||
sha: ''
|
||||
} : undefined;
|
||||
|
||||
// Need: hashmap of filename -> promotion
|
||||
try {
|
||||
await this.cblService.finalizeImport(file.fileName, decisions, file.provider, repoMeta).toPromise();
|
||||
await this.cblService.finalizeImport(file.fileName, decisions, file.provider, this.getPromoteForFile(file.fileName), repoMeta).toPromise();
|
||||
} catch {
|
||||
this.toastr.error(translate('toasts.failed-to-import', {name: file.name}));
|
||||
}
|
||||
@@ -407,6 +423,11 @@ export class ImportCblModalComponent implements OnInit {
|
||||
}));
|
||||
|
||||
this.allRows.set(rows);
|
||||
|
||||
const fileName = this.currentFile().fileName;
|
||||
if (this.promoteMap()[fileName] === undefined) {
|
||||
this.promoteMap.update(m => ({ ...m, [fileName]: this.defaultPromotionState() }));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSeriesSelection(row: CblIssueRow, seriesId: number) {
|
||||
@@ -465,6 +486,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
return a.seriesId === b.seriesId;
|
||||
};
|
||||
settings.dropdownPosition = 'body';
|
||||
settings.overlayMinWidth = 400;
|
||||
|
||||
return settings;
|
||||
}
|
||||
@@ -501,6 +523,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
return a.id === b.id;
|
||||
};
|
||||
settings.dropdownPosition = 'body';
|
||||
settings.overlayMinWidth = 280;
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<div class="position-relative">
|
||||
@if (!accountService.hasReadOnlyRole()) {
|
||||
<div class="position-absolute custom-position d-flex gap-2">
|
||||
<div class="position-absolute custom-position d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-outline-primary" [disabled]="selectedList() === undefined" (click)="selectedList.set(undefined)" [title]="t('add')">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
||||
</button>
|
||||
@@ -17,14 +17,14 @@
|
||||
|
||||
|
||||
<div class="row g-0 theme-container">
|
||||
<div class="col-lg-5 col-md-6 col-sm-7 col-xs-7 scroller">
|
||||
<div class="col-lg-5 col-md-6 col-sm-12 scroller">
|
||||
<div class="pe-2">
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control form-control-sm" [placeholder]="t('search-placeholder')"
|
||||
[value]="searchTerm()" (input)="searchTerm.set($any($event.target).value)" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2 filter-row">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="hasUpdateFilter"
|
||||
[checked]="hasUpdateFilter()" (change)="hasUpdateFilter.set($any($event.target).checked)">
|
||||
@@ -59,7 +59,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 col-md-6 col-sm-4 col-xs-4 ps-3">
|
||||
<div class="col-lg-7 col-md-6 col-sm-12 ps-3 ps-sm-3 ps-0 pt-3 pt-md-0 detail-panel"
|
||||
[class.is-hidden-mobile]="!selectedList() && !showUploadFlow()">
|
||||
<div class="card p-3">
|
||||
|
||||
@let selectedItem = selectedList();
|
||||
@@ -93,17 +94,37 @@
|
||||
<app-image class="detail-cover" [imageUrl]="imageService.getReadingListCoverImage(selectedItem.id)" />
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start gap-2">
|
||||
|
||||
<h4 class="mb-1">{{selectedItem.title}}</h4>
|
||||
<div class="d-flex gap-2">
|
||||
@if (!accountService.hasReadOnlyRole()) {
|
||||
<button class="btn btn-outline-danger btn-sm" (click)="deleteList(selectedItem)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('delete')}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (selectedItem.canSync) {
|
||||
<button class="btn btn-primary btn-sm" (click)="syncReadingList(selectedItem)">{{t('sync')}}</button>
|
||||
}
|
||||
|
||||
<div class="flex-shrink-0 ms-2">
|
||||
|
||||
<div class="d-flex flex-row gap-2">
|
||||
@if (!accountService.hasReadOnlyRole()) {
|
||||
<button class="btn btn-outline-danger btn-sm" (click)="deleteList(selectedItem)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('delete')}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (selectedItem.canSync) {
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="syncReadingList(selectedItem)">
|
||||
<span class="read-btn--text">{{t('sync')}}</span>
|
||||
</button>
|
||||
|
||||
<div class="btn-group" ngbDropdown role="group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="manualSyncReadingList(selectedItem)">
|
||||
<span class="read-btn--text">{{t('sync-manual')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,22 +162,22 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<div class="mt-1">
|
||||
<div class="d-flex">
|
||||
<div class="mt-1">
|
||||
@if (selectedItem.canSync) {
|
||||
<div class="mt-3 text-muted" style="font-size: 0.8rem;">
|
||||
<div>{{t('last-synced', {date: (selectedItem.lastSyncedUtc | utcToLocalDate | timeAgo)})}}</div>
|
||||
<div>{{t('last-checked', {date: (selectedItem.lastSyncCheckUtc | utcToLocalDate | timeAgo)})}}</div>
|
||||
@if (selectedItem.canSync) {
|
||||
<div class="mt-3 text-muted" style="font-size: 0.8rem;">
|
||||
<div>{{t('last-synced', {date: (selectedItem.lastSyncedUtc | utcToLocalDate | timeAgo)})}}</div>
|
||||
<div>{{t('last-checked', {date: (selectedItem.lastSyncCheckUtc | utcToLocalDate | timeAgo)})}}</div>
|
||||
@if (selectedItem.canSync) {
|
||||
<div>
|
||||
<a [href]="(selectedItem.downloadUrl ?? selectedItem.sourcePath) | safeUrl" target="_blank" rel="noopener noreferrer">{{t('source')}}</a>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<a [href]="(selectedItem.downloadUrl ?? selectedItem.sourcePath) | safeUrl" target="_blank" rel="noopener noreferrer">{{t('source')}}</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
|
||||
ngx-file-drop ::ng-deep > div {
|
||||
// styling for the outer drop box
|
||||
width: 100%;
|
||||
border: 0.125rem solid var(--primary-color);
|
||||
border-radius: 0.3125rem;
|
||||
height: 6.25rem;
|
||||
margin: auto;
|
||||
|
||||
> div {
|
||||
// styling for the inner box (template)
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 0.9375rem;
|
||||
top: -2.625rem;
|
||||
@media (max-width: 576px) {
|
||||
position: static !important;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -31,7 +19,8 @@ ngx-file-drop ::ng-deep > div {
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-height: calc(100dvh - 30rem);
|
||||
max-height: 50dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
> div {
|
||||
@@ -59,8 +48,20 @@ ngx-file-drop ::ng-deep > div {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
font-size: 0.8rem;
|
||||
.filter-row {
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
@media (max-width: 767px) {
|
||||
&.is-hidden-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
@@ -75,3 +76,13 @@ ngx-file-drop ::ng-deep > div {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-group > .btn.dropdown-toggle-split {
|
||||
border-top-right-radius: var(--bs-border-radius) !important;
|
||||
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||
border-width: 1px 1px 1px 0 !important;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
|
||||
import {AgeRatingImageComponent} from "../../_single-module/age-rating-image/age-rating-image.component";
|
||||
import {DateYearRangePipe} from "../../_pipes/date-year-range.pipe";
|
||||
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-cbl-manager',
|
||||
@@ -52,7 +52,11 @@ import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
TimeAgoPipe,
|
||||
AgeRatingImageComponent,
|
||||
SafeUrlPipe,
|
||||
NgbTooltip
|
||||
NgbTooltip,
|
||||
NgbDropdown,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownToggle
|
||||
],
|
||||
templateUrl: './cbl-manager.component.html',
|
||||
styleUrl: './cbl-manager.component.scss',
|
||||
@@ -185,6 +189,12 @@ export class CblManagerComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
manualSyncReadingList(list: ReadingList) {
|
||||
this.cblService.importFromUrl(list.downloadUrl!).subscribe((savedFile) => {
|
||||
this.openImportModal([savedFile]);
|
||||
});
|
||||
}
|
||||
|
||||
getDateRangeLabel(rl: ReadingList) {
|
||||
if (!rl || rl.startingYear === 0) return null;
|
||||
|
||||
@@ -192,9 +202,9 @@ export class CblManagerComponent implements OnInit {
|
||||
const startMonth = rl.startingMonth > 0 ? rl.startingMonth - 1 : undefined;
|
||||
const endMonth = rl.startingMonth > 0 ? rl.endingMonth - 1 : undefined;
|
||||
|
||||
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear);
|
||||
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear, 0);
|
||||
const endDate = rl.endingYear <= 0 ? null :
|
||||
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear));
|
||||
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear, 0));
|
||||
|
||||
return this.dateYearRangePipe.transform(startDate, endDate, !!endMonth);
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
[genres]="genres()"
|
||||
[tags]="tags()"
|
||||
[files]="files()"
|
||||
[basicMetadata]="volumeBasicMetadata()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -45,7 +45,7 @@ import {AgeRating} from '../_models/metadata/age-rating';
|
||||
import {Volume} from "../_models/volume";
|
||||
import {VolumeService} from "../_services/volume.service";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
|
||||
import {BasicMetadataInfo, DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
|
||||
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
||||
import {Person} from "../_models/metadata/person";
|
||||
import {IHasCast} from "../_models/common/i-has-cast";
|
||||
@@ -289,6 +289,18 @@ export class VolumeDetailComponent implements OnInit {
|
||||
return translate(chapterLocaleKey, {num: currentlyReadingChapter.minNumber});
|
||||
})
|
||||
|
||||
volumeBasicMetadata = computed<BasicMetadataInfo>(() => {
|
||||
const v = this.volume();
|
||||
return {
|
||||
readingTime: v,
|
||||
pages: v.pages,
|
||||
words: v.wordCount,
|
||||
addedAt: v.createdUtc,
|
||||
updatedAt: v.lastModifiedUtc,
|
||||
kavitaId: v.id,
|
||||
};
|
||||
});
|
||||
|
||||
volumeCast = computed<VolumeCast>(() => {
|
||||
const chapters = this.volume()?.chapters || [];
|
||||
return {
|
||||
|
||||
@@ -1560,9 +1560,27 @@
|
||||
"format-title": "{{metadata-filter.format-label}}",
|
||||
"length-title": "{{edit-chapter-modal.words-label}}",
|
||||
"age-rating-title": "{{metadata-fields.age-rating-title}}",
|
||||
"folder-path-title": "Folder path",
|
||||
"folder-path-title": "{{edit-series-modal.folder-path-title}}",
|
||||
"file-path-title": "Files",
|
||||
"external-metadata-title": "External Metadata"
|
||||
"external-metadata-title": "External Metadata",
|
||||
"basic-metadata-title": "Basic Metadata",
|
||||
"read-time-label": "Read time",
|
||||
"pages-label": "{{edit-chapter-modal.pages-label}}",
|
||||
"words-label": "{{edit-chapter-modal.words-label}}",
|
||||
"added-label": "Added",
|
||||
"updated-label": "Updated",
|
||||
"kavita-id-label": "Kavita ID",
|
||||
"sort-order-label": "{{edit-chapter-modal.sort-order-label}}",
|
||||
"language-label": "Language",
|
||||
"is-special-label": "Special",
|
||||
"pub-status-label": "{{metadata-filter.publication-status-label}}",
|
||||
"yes-label": "Yes",
|
||||
"no-label": "No",
|
||||
"file-info-title": "File info",
|
||||
"not-set-label": "Not set",
|
||||
"pages-count": "{{series-detail.pages-count}}",
|
||||
"words-count": "{{series-detail.words-count}}",
|
||||
"bytes-count": "{{num}} bytes"
|
||||
},
|
||||
|
||||
"related-tab": {
|
||||
@@ -2124,6 +2142,7 @@
|
||||
"no-results": "No matching reading lists",
|
||||
"add": "{{common.add}}",
|
||||
"sync": "Sync",
|
||||
"sync-manual": "Sync (Manual)",
|
||||
"delete": "{{common.delete}}",
|
||||
"view-list": "View Reading List",
|
||||
"last-synced": "Last Synced: {{date}}",
|
||||
@@ -2173,7 +2192,9 @@
|
||||
"volume-num": "{{common.volume-num-shorthand}}",
|
||||
"issue-num": "{{common.issue-num-shorthand}}",
|
||||
"refresh": "Refresh",
|
||||
"wiki": "Wiki"
|
||||
"wiki": "Wiki",
|
||||
"promote": "{{actionable.promote}}",
|
||||
"promote-tooltip": "{{actionable.promote-tooltip}}"
|
||||
},
|
||||
|
||||
"manage-remap-rules-modal": {
|
||||
@@ -3545,7 +3566,9 @@
|
||||
"readinglist-item-count": "{{sort-field-pipe.readinglist-item-count}}",
|
||||
"readinglist-tags": "{{metadata-fields.tags-title}}",
|
||||
"readinglist-writer": "{{person-role-pipe.writer}}",
|
||||
"readinglist-artist": "{{person-role-pipe.artist}}"
|
||||
"readinglist-artist": "{{person-role-pipe.artist}}",
|
||||
"readinglist-provider": "Provider",
|
||||
"readinglist-missing-item-count": "Missing Item Count"
|
||||
},
|
||||
|
||||
|
||||
@@ -3801,7 +3824,9 @@
|
||||
"export-v1": "CBL v1",
|
||||
"export-v1-tooltip": "Use CBL v1 (XML)",
|
||||
"export-v2": "CBL v2",
|
||||
"export-v2-tooltip": "Use CBL v2 (JSON)"
|
||||
"export-v2-tooltip": "Use CBL v2 (JSON)",
|
||||
"cbl-manager": "CBL Manager",
|
||||
"cbl-manager-tooltip": "Manage/Sync CBLs"
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
@@ -3944,7 +3969,9 @@
|
||||
"mangaBakaId-label": "MangaBaka Id",
|
||||
"hardcoverId-label": "Hardcover Id",
|
||||
"comicVineId-label": "Comic Vine Id",
|
||||
"metronId-label": "Metron Id"
|
||||
"metronId-label": "Metron Id",
|
||||
"not-set-label": "Not set",
|
||||
"isbn-label": "{{edit-chapter-modal.isbn-label}}"
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
@use './theme/components/typeahead';
|
||||
@use './theme/components/tooltip';
|
||||
@use './theme/components/stat-card';
|
||||
@use './theme/components/headers';
|
||||
|
||||
@use './theme/utilities/headings';
|
||||
@use './theme/utilities/utilities';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Needed for Details page to scope into carousel */
|
||||
.kv-section-header {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted-color);
|
||||
margin-bottom: 0.625rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
@@ -236,6 +236,18 @@
|
||||
--tagbadge-filled-border-color: rgba(239, 239, 239, 0.125);
|
||||
--tagbadge-filled-text-color: var(--body-text-color);
|
||||
--tagbadge-filled-bg-color: var(--primary-color);
|
||||
--tagbadge-pill-hover-bg-color: rgba(74, 198, 148, 0.15);
|
||||
|
||||
/* Label Card */
|
||||
--label-card-bg: rgba(255, 255, 255, 0.04);
|
||||
--label-card-border: rgba(239, 239, 239, 0.07);
|
||||
--label-card-icon-color: var(--primary-color);
|
||||
--label-card-label-color: rgba(255, 255, 255, 0.4);
|
||||
--label-card-value-color: #efefef;
|
||||
--label-card-value-muted-color: rgba(255, 255, 255, 0.25);
|
||||
|
||||
/* File path */
|
||||
--file-path-color: rgba(255, 255, 255, 0.55);
|
||||
|
||||
/* Side Nav */
|
||||
--side-nav-width: 14.375rem;
|
||||
@@ -490,6 +502,7 @@
|
||||
|
||||
/** Misc **/
|
||||
--offwhite-text-color: #8b95a5;
|
||||
--white-text-color: white;
|
||||
|
||||
/** Activity Card **/
|
||||
--activity-card-client-platform-badge-bg-color: #8b5cf6;
|
||||
|
||||
Reference in New Issue
Block a user