UX Overhaul Part 2 (#3112)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-08-16 19:37:12 -05:00 committed by GitHub
parent 0247bc5012
commit 3d8aa2ad24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
192 changed files with 14808 additions and 1874 deletions

View file

@ -0,0 +1,124 @@
<ng-container *transloco="let t; read: 'details-tab'">
<div class="mb-3">
<app-carousel-reel [items]="genres" [title]="t('genres-title')">
<ng-template #carouselItem let-item>
<app-tag-badge (click)="openGeneric(FilterField.Genres, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
{{item.title}}
</app-tag-badge>
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="tags" [title]="t('tags-title')">
<ng-template #carouselItem let-item>
<app-tag-badge (click)="openGeneric(FilterField.Tags, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
{{item.title}}
</app-tag-badge>
</ng-template>
</app-carousel-reel>
</div>
@if (genres.length > 0 || tags.length > 0) {
<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" (click)="openPerson(FilterField.Writers, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Colorist, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Editor, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.CoverArtist, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Inker, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Letterer, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Penciller, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Translators, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Characters, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Location, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Team, item)"></app-person-badge>
</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" (click)="openPerson(FilterField.Imprint, item)"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
</ng-container>

View file

@ -0,0 +1,52 @@
import {ChangeDetectionStrategy, Component, 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 {Person, PersonRole} from "../../_models/metadata/person";
import {Router} from "@angular/router";
import {FilterField} from "../../_models/metadata/v2/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 {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component";
@Component({
selector: 'app-details-tab',
standalone: true,
imports: [
CarouselReelComponent,
PersonBadgeComponent,
TranslocoDirective,
TagBadgeComponent
],
templateUrl: './details-tab.component.html',
styleUrl: './details-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DetailsTabComponent {
private readonly router = inject(Router);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly PersonRole = PersonRole;
protected readonly FilterField = FilterField;
@Input({required: true}) metadata!: IHasCast;
@Input() genres: Array<Genre> = [];
@Input() tags: Array<Tag> = [];
openPerson(queryParamName: FilterField, filter: Person) {
if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter.id}`).subscribe();
}
openGeneric(queryParamName: FilterField, filter: string | number) {
if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
}
protected readonly TagBadgeCursor = TagBadgeCursor;
}

View file

@ -0,0 +1,610 @@
<ng-container *transloco="let t; read: 'edit-chapter-modal'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal" [ngClass]="{'d-flex': utilityService.getActiveBreakpoint() !== Breakpoint.Mobile}">
<form [formGroup]="editForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<!-- General Tab -->
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{t(TabID.General)}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="col-md-9 col-sm-12 mb-3">
<app-setting-item [title]="t('title-label')" [showEdit]="false" [toggleOnViewClick]="false">
<ng-template #view>
@if (editForm.get('titleName'); as formControl) {
<div class="input-group" [ngClass]="{'lock-active': chapter.titleNameLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'titleNameLocked' }"></ng-container>
<input class="form-control" formControlName="titleName" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if (formControl.errors; as errors) {
<div class="invalid-feedback">
@if (errors.required) {
<div>{{t('required-field')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
</div>
<div class="col-md-3 col-sm-12 mb-3">
<app-setting-item [title]="t('sort-order-label')" [showEdit]="false" [toggleOnViewClick]="false">
<ng-template #view>
@if (editForm.get('sortOrder'); as formControl) {
<div class="input-group" [ngClass]="{'lock-active': chapter.sortOrderLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'sortOrderLocked' }"></ng-container>
<input class="form-control" formControlName="sortOrder" type="number" min="0" step="0.1" inputmode="numeric"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if (formControl.errors; as errors) {
<div class="invalid-feedback">
@if (errors.required) {
<div>{{t('required-field')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="row g-0">
<div class="col-md-9 col-sm-12 mb-3">
<app-setting-item [title]="t('isbn-label')" [showEdit]="false" [toggleOnViewClick]="false">
<ng-template #view>
@if (editForm.get('isbn'); as formControl) {
<div class="input-group" [ngClass]="{'lock-active': chapter.isbnLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'isbnLocked' }"></ng-container>
<input class="form-control" formControlName="isbn" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if (formControl.errors; as errors) {
<div class="invalid-feedback">
@if (errors.required) {
<div>{{t('required-field')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
</div>
<div class="col-md-3 col-sm-12 mb-3">
<app-setting-item [title]="t('age-rating-label')" [showEdit]="false" [toggleOnViewClick]="false">
<ng-template #view>
@if (editForm.get('ageRating'); as formControl) {
<div class="input-group" [ngClass]="{'lock-active': chapter.ageRatingLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'ageRatingLocked' }"></ng-container>
<select class="form-select" id="age-rating" formControlName="ageRating">
@for(opt of ageRatings; track opt.value) {
<option [value]="opt.value">{{opt.title | titlecase}}</option>
}
</select>
</div>
}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="row g-0">
<div class="col-lg-9 col-md-12">
<div class="mb-3">
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
(newItemAdded)="chapter.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-3 col-md-12">
<div class="mb-3">
<app-setting-item [title]="t('release-date-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'releaseDateLocked' }"></ng-container>
<input
class="form-control"
formControlName="releaseDate"
type="date"
/>
</div>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-item [title]="t('summary-label')" [showEdit]="false" [toggleOnViewClick]="false">
<ng-template #view>
@if (editForm.get('summary'); as formControl) {
<div class="input-group" [ngClass]="{'lock-active': chapter.summaryLocked}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'summaryLocked' }"></ng-container>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
}
</ng-template>
</app-setting-item>
</div>
</div>
</ng-template>
</li>
<!-- Tags Tab -->
<li [ngbNavItem]="TabID.Tags">
<a ngbNavLink>{{t(TabID.Tags)}}</a>
<ng-template ngbNavContent>
<!-- genre & tag -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
(newItemAdded)="chapter.genresLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
(newItemAdded)="chapter.tagsLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- imprint & publisher -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
(newItemAdded)="chapter.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
(newItemAdded)="chapter.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- team & location -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
(newItemAdded)="chapter.teamLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
(newItemAdded)="chapter.locationLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- character -->
<div class="row g-0">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
(newItemAdded)="chapter.characterLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
</ng-template>
</li>
<!-- People Tab -->
<li [ngbNavItem]="TabID.People">
<a ngbNavLink>{{t(TabID.People)}}</a>
<ng-template ngbNavContent>
<!-- writer & cover artist -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
(newItemAdded)="chapter.writerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
(newItemAdded)="chapter.coverArtistLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- penciller & colorist -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
(newItemAdded)="chapter.pencillerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
(newItemAdded)="chapter.coloristLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- inker & letterer -->
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
(newItemAdded)="chapter.inkerLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
(newItemAdded)="chapter.lettererLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<!-- translator -->
<div class="row g-0">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
(newItemAdded)="chapter.translatorLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
</ng-template>
</li>
<!-- Cover Tab -->
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
<ng-template ngbNavContent>
<p class="alert alert-warning" role="alert">
{{t('cover-image-description')}}
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
[showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<!-- Info Tab -->
<li [ngbNavItem]="TabID.Info">
<a ngbNavLink>{{t(TabID.Info)}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('pages-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{t('pages-count', {num: chapter.pages | compactNumber})}}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('words-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{t('words-count', {num: chapter.wordCount | compactNumber})}}
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('read-time-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{chapter | readTime }}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('size-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{size | bytes}}
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('date-added-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('id-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{chapter.id}}
</ng-template>
</app-setting-item>
</div>
</div>
</div>
@if (WebLinks.length > 0) {
<div class="setting-section-break"></div>
<div class="row g-0">
<div class="col-auto">
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
@for(link of WebLinks; track link) {
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
}
</app-icon-and-title>
</div>
</div>
}
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
@for (file of chapter.files; track file.id) {
<div>
<span>{{file.filePath}}</span><span class="ms-2 me-2"></span><span>{{file.bytes | bytes}}</span>
</div>
}
</ng-template>
</app-setting-item>
</ng-template>
</li>
<!-- Progress Tab -->
<li [ngbNavItem]="TabID.Progress">
<a ngbNavLink>{{t(TabID.Progress)}}</a>
<ng-template ngbNavContent>
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
</ng-template>
</li>
<!-- Tasks Tab -->
<li [ngbNavItem]="TabID.Tasks">
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
<ng-template ngbNavContent>
@for(task of tasks; track task.action) {
<div class="mt-3 mb-3">
<app-setting-button [subtitle]="task.description">
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
</app-setting-button>
</div>
}
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="!editForm.valid" (click)="save()">{{t('save')}}</button>
</div>
</div>
<ng-template #lock let-item="item" let-field="field">
<span class="input-group-text clickable" (click)="unlock(item, field)">
<i class="fa fa-lock" aria-hidden="true"></i>
<span class="visually-hidden">{{t('field-locked-alt')}}</span>
</span>
</ng-template>
</ng-container>

View file

@ -0,0 +1,6 @@
.lock-active {
> .input-group-text {
background-color: var(--primary-color);
color: white;
}
}

View file

@ -0,0 +1,534 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {
AsyncPipe,
DatePipe,
DecimalPipe,
NgClass,
NgTemplateOutlet,
TitleCasePipe
} from "@angular/common";
import {
NgbActiveModal,
NgbInputDatepicker,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Chapter} from "../../_models/chapter";
import {LibraryType} from "../../_models/library/library";
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
import {Tag} from "../../_models/tag";
import {Language} from "../../_models/metadata/language";
import {Person, PersonRole} from "../../_models/metadata/person";
import {Genre} from "../../_models/metadata/genre";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {SeriesService} from "../../_services/series.service";
import {ImageService} from "../../_services/image.service";
import {UploadService} from "../../_services/upload.service";
import {MetadataService} from "../../_services/metadata.service";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionService} from "../../_services/action.service";
import {DownloadService} from "../../shared/_services/download.service";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {forkJoin, Observable, of} from "rxjs";
import {map} from "rxjs/operators";
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {MangaFormat} from "../../_models/manga-format";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {TranslocoDatePipe} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {ImageComponent} from "../../shared/image/image.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
import {ChapterService} from "../../_services/chapter.service";
enum TabID {
General = 'general-tab',
CoverImage = 'cover-image-tab',
Info = 'info-tab',
People = 'people-tab',
Tasks = 'tasks-tab',
Progress = 'progress-tab',
Tags = 'tags-tab'
}
export interface EditChapterModalCloseResult {
success: boolean;
chapter: Chapter;
coverImageUpdate: boolean;
needsReload: boolean;
isDeleted: boolean;
}
const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
@Component({
selector: 'app-edit-chapter-modal',
standalone: true,
imports: [
FormsModule,
NgbNav,
NgbNavContent,
NgbNavLink,
TranslocoDirective,
AsyncPipe,
NgbNavOutlet,
ReactiveFormsModule,
NgbNavItem,
SettingItemComponent,
NgTemplateOutlet,
NgClass,
TypeaheadComponent,
EntityTitleComponent,
TitleCasePipe,
SettingButtonComponent,
CoverImageChooserComponent,
EditChapterProgressComponent,
NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe,
IconAndTitleComponent,
DefaultDatePipe,
TranslocoDatePipe,
UtcToLocalTimePipe,
BytesPipe,
ImageComponent,
SafeHtmlPipe,
DecimalPipe,
DatePipe,
ReadTimePipe
],
templateUrl: './edit-chapter-modal.component.html',
styleUrl: './edit-chapter-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditChapterModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly seriesService = inject(SeriesService);
public readonly utilityService = inject(UtilityService);
public readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly downloadService = inject(DownloadService);
private readonly chapterService = inject(ChapterService);
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly Action = Action;
protected readonly PersonRole = PersonRole;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) chapter!: Chapter;
@Input({required: true}) libraryType!: LibraryType;
@Input({required: true}) libraryId!: number;
@Input({required: true}) seriesId!: number;
activeId = TabID.General;
editForm: FormGroup = new FormGroup({});
selectedCover: string = '';
coverImageReset = false;
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
tags: Tag[] = [];
genres: Genre[] = [];
ageRatings: Array<AgeRatingDto> = [];
validLanguages: Array<Language> = [];
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList);
/**
* A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified
*/
initChapter!: Chapter;
imageUrls: Array<string> = [];
size: number = 0;
get WebLinks() {
if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(',');
}
ngOnInit() {
this.initChapter = Object.assign({}, this.chapter);
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0);
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
this.editForm.addControl('summary', new FormControl(this.chapter.summary, []));
this.editForm.addControl('language', new FormControl(this.chapter.language, []));
this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, []));
this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, []));
this.editForm.addControl('releaseDate', new FormControl(this.chapter.releaseDate, []));
this.editForm.addControl('genres', new FormControl(this.chapter.genres, []));
this.editForm.addControl('tags', new FormControl(this.chapter.tags, []));
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
this.editForm.addControl('coverImageLocked', new FormControl(this.chapter.coverImageLocked, []));
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
this.validLanguages = validLanguages;
this.setupLanguageTypeahead();
this.cdRef.markForCheck();
});
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
this.editForm.get('titleName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.titleNameLocked = true;
this.cdRef.markForCheck();
});
this.editForm.get('sortOrder')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.sortOrderLocked = true;
this.cdRef.markForCheck();
});
this.editForm.get('isbn')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.isbnLocked = true;
this.cdRef.markForCheck();
});
this.editForm.get('ageRating')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.ageRatingLocked = true;
this.cdRef.markForCheck();
});
this.editForm.get('summary')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.summaryLocked = true;
this.cdRef.markForCheck();
});
this.editForm.get('releaseDate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.chapter.releaseDateLocked = true;
this.cdRef.markForCheck();
});
this.setupTypeaheads();
}
close() {
this.modal.dismiss();
}
save() {
const model = this.editForm.value;
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
this.chapter.releaseDate = model.releaseDate;
const apis = [
this.chapterService.updateChapter(this.chapter)
];
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
const needsReload = this.editForm.get('titleName')?.dirty || this.editForm.get('sortOrder')?.dirty;
if (selectedIndex > 0 || this.coverImageReset) {
apis.push(this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover, !this.coverImageReset));
}
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult);
});
}
unlock(b: any, field: string) {
if (b) {
b[field] = !b[field];
}
this.cdRef.markForCheck();
}
async runTask(action: ActionItem<Chapter>) {
switch (action.action) {
case Action.MarkAsRead:
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, this.chapter, (p) => {
this.chapter.pagesRead = p.pagesRead;
this.cdRef.markForCheck();
});
break;
case Action.MarkAsUnread:
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, this.chapter, (p) => {
this.chapter.pagesRead = 0;
this.cdRef.markForCheck();
});
break;
case Action.Delete:
await this.actionService.deleteChapter(this.chapter.id, (b) => {
if (!b) return;
this.modal.close({success: b, chapter: this.chapter, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditChapterModalCloseResult);
});
break;
case Action.Download:
this.downloadService.download('chapter', this.chapter);
break;
}
}
setupTypeaheads() {
forkJoin([
this.setupTagSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
this.setupLanguageTypeahead()
]).subscribe(results => {
this.cdRef.markForCheck();
});
}
setupTagSettings() {
this.tagsSettings.minCharacters = 0;
this.tagsSettings.multiple = true;
this.tagsSettings.id = 'tags';
this.tagsSettings.unique = true;
this.tagsSettings.showLocked = true;
this.tagsSettings.addIfNonExisting = true;
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags()
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
this.tagsSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => {
return a.title.toLowerCase() == b.title.toLowerCase();
}
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
if (this.chapter.tags) {
this.tagsSettings.savedData = this.chapter.tags;
}
return of(true);
}
setupGenreTypeahead() {
this.genreSettings.minCharacters = 0;
this.genreSettings.multiple = true;
this.genreSettings.id = 'genres';
this.genreSettings.unique = true;
this.genreSettings.showLocked = true;
this.genreSettings.addIfNonExisting = true;
this.genreSettings.fetchFn = (filter: string) => {
return this.metadataService.getAllGenres()
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
};
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => {
return a.title.toLowerCase() == b.title.toLowerCase();
}
this.genreSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
if (this.chapter.genres) {
this.genreSettings.savedData = this.chapter.genres;
}
return of(true);
}
setupLanguageTypeahead() {
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
return of(true);
}
updateFromPreset(id: string, presetField: Array<Person> | undefined, role: PersonRole) {
const personSettings = this.createBlankPersonSettings(id, role)
if (presetField && presetField.length > 0) {
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
return fetch('').pipe(map(people => {
const presetIds = presetField.map(p => p.id);
personSettings.savedData = people.filter(person => presetIds.includes(person.id));
this.peopleSettings[role] = personSettings;
this.metadataService.updatePerson(this.chapter, personSettings.savedData as Person[], role);
this.cdRef.markForCheck();
return true;
}));
} else {
this.peopleSettings[role] = personSettings;
return of(true);
}
}
setupPersonTypeahead() {
this.peopleSettings = {};
return forkJoin([
this.updateFromPreset('writer', this.chapter.writers, PersonRole.Writer),
this.updateFromPreset('character', this.chapter.characters, PersonRole.Character),
this.updateFromPreset('colorist', this.chapter.colorists, PersonRole.Colorist),
this.updateFromPreset('cover-artist', this.chapter.coverArtists, PersonRole.CoverArtist),
this.updateFromPreset('editor', this.chapter.editors, PersonRole.Editor),
this.updateFromPreset('inker', this.chapter.inkers, PersonRole.Inker),
this.updateFromPreset('letterer', this.chapter.letterers, PersonRole.Letterer),
this.updateFromPreset('penciller', this.chapter.pencillers, PersonRole.Penciller),
this.updateFromPreset('publisher', this.chapter.publishers, PersonRole.Publisher),
this.updateFromPreset('imprint', this.chapter.imprints, PersonRole.Imprint),
this.updateFromPreset('translator', this.chapter.translators, PersonRole.Translator),
this.updateFromPreset('teams', this.chapter.teams, PersonRole.Team),
this.updateFromPreset('locations', this.chapter.locations, PersonRole.Location),
]).pipe(map(results => {
return of(true);
}));
}
fetchPeople(role: PersonRole, filter: string) {
return this.metadataService.getAllPeople().pipe(map(people => {
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
}));
}
createBlankPersonSettings(id: string, role: PersonRole) {
var personSettings = new TypeaheadSettings<Person>();
personSettings.minCharacters = 0;
personSettings.multiple = true;
personSettings.showLocked = true;
personSettings.unique = true;
personSettings.addIfNonExisting = true;
personSettings.id = id;
personSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
personSettings.compareFnForAdd = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.name, filter));
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role };
});
return personSettings;
}
updateTags(tags: Tag[]) {
this.tags = tags;
this.chapter.tags = tags;
this.cdRef.markForCheck();
}
updateGenres(genres: Genre[]) {
this.genres = genres;
this.chapter.genres = genres;
this.cdRef.markForCheck();
}
updatePerson(persons: Person[], role: PersonRole) {
this.metadataService.updatePerson(this.chapter, persons, role);
this.chapter.locationLocked = true;
this.cdRef.markForCheck();
}
updateLanguage(language: Array<Language>) {
if (language.length === 0) {
this.chapter.language = '';
return;
}
this.chapter.language = language[0].isoCode;
this.chapter.languageLocked = true;
this.cdRef.markForCheck();
}
updateSelectedIndex(index: number) {
this.editForm.patchValue({
coverImageIndex: index
});
this.cdRef.markForCheck();
}
updateSelectedImage(url: string) {
this.selectedCover = url;
this.cdRef.markForCheck();
}
handleReset() {
this.coverImageReset = true;
this.editForm.patchValue({
coverImageLocked: false
});
this.cdRef.markForCheck();
}
getPersonsSettings(role: PersonRole) {
return this.peopleSettings[role];
}
}

View file

@ -0,0 +1,143 @@
<ng-container *transloco="let t; read: 'edit-volume-modal'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="false"></app-entity-title></h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal" [ngClass]="{'d-flex': utilityService.getActiveBreakpoint() !== Breakpoint.Mobile}">
<form [formGroup]="editForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<!-- Cover Tab -->
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
<ng-template ngbNavContent>
<p class="alert alert-warning" role="alert">
{{t('cover-image-description')}}
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
[showReset]="volume.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<!-- Info Tab -->
<li [ngbNavItem]="TabID.Info">
<a ngbNavLink>{{t(TabID.Info)}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('pages-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{t('pages-count', {num: volume.pages | compactNumber})}}
</ng-template>
</app-setting-item>
</div>
</div>
<!-- <div class="col-lg-6 col-md-12 pe-2">-->
<!-- <div class="mb-3">-->
<!-- <app-setting-item [title]="t('words-label')" [toggleOnViewClick]="false" [showEdit]="false">-->
<!-- <ng-template #view>-->
<!-- {{t('words-count', {num: volume.wordCount | compactNumber})}}-->
<!-- </ng-template>-->
<!-- </app-setting-item>-->
<!-- </div>-->
<!-- </div>-->
</div>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('read-time-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{volume | readTime }}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('size-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{size | bytes}}
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('date-added-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{volume.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
</ng-template>
</app-setting-item>
</div>
</div>
<div class="col-lg-6 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('id-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
{{volume.id}}
</ng-template>
</app-setting-item>
</div>
</div>
</div>
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
@for (file of files; track file.id) {
<div>
<span>{{file.filePath}}</span><span class="ms-2 me-2"></span><span>{{file.bytes | bytes}}</span>
</div>
}
</ng-template>
</app-setting-item>
</ng-template>
</li>
<!-- Progress Tab -->
<li [ngbNavItem]="TabID.Progress">
<a ngbNavLink>{{t(TabID.Progress)}}</a>
<ng-template ngbNavContent>
@for(chapter of volume.chapters; track chapter.id) {
<h6><app-entity-title [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h6>
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
<div class="setting-section-break"></div>
}
</ng-template>
</li>
<!-- Tasks Tab -->
<li [ngbNavItem]="TabID.Tasks">
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
<ng-template ngbNavContent>
@for(task of tasks; track task.action) {
<div class="mt-3 mb-3">
<app-setting-button [subtitle]="task.description">
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
</app-setting-button>
</div>
}
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="!editForm.valid" (click)="save()">{{t('save')}}</button>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,231 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
import {
NgbActiveModal,
NgbInputDatepicker,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {TranslocoDatePipe} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {ImageComponent} from "../../shared/image/image.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {Volume} from "../../_models/volume";
import {SeriesService} from "../../_services/series.service";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ImageService} from "../../_services/image.service";
import {UploadService} from "../../_services/upload.service";
import {MetadataService} from "../../_services/metadata.service";
import {AccountService} from "../../_services/account.service";
import {ActionService} from "../../_services/action.service";
import {DownloadService} from "../../shared/_services/download.service";
import {Chapter} from "../../_models/chapter";
import {LibraryType} from "../../_models/library/library";
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
import {Tag} from "../../_models/tag";
import {Language} from "../../_models/metadata/language";
import {Person, PersonRole} from "../../_models/metadata/person";
import {Genre} from "../../_models/metadata/genre";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {forkJoin, Observable, of} from "rxjs";
import {map} from "rxjs/operators";
import {EditChapterModalCloseResult} from "../edit-chapter-modal/edit-chapter-modal.component";
import { MangaFormat } from 'src/app/_models/manga-format';
import {MangaFile} from "../../_models/manga-file";
import {VolumeService} from "../../_services/volume.service";
enum TabID {
General = 'general-tab',
CoverImage = 'cover-image-tab',
Info = 'info-tab',
Tasks = 'tasks-tab',
Progress = 'progress-tab',
}
export interface EditVolumeModalCloseResult {
success: boolean;
volume: Volume;
coverImageUpdate: boolean;
needsReload: boolean;
isDeleted: boolean;
}
const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
@Component({
selector: 'app-edit-volume-modal',
standalone: true,
imports: [
FormsModule,
NgbNav,
NgbNavContent,
NgbNavLink,
TranslocoDirective,
AsyncPipe,
NgbNavOutlet,
ReactiveFormsModule,
NgbNavItem,
SettingItemComponent,
NgTemplateOutlet,
NgClass,
TypeaheadComponent,
EntityTitleComponent,
TitleCasePipe,
SettingButtonComponent,
CoverImageChooserComponent,
EditChapterProgressComponent,
NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe,
IconAndTitleComponent,
DefaultDatePipe,
TranslocoDatePipe,
UtcToLocalTimePipe,
BytesPipe,
ImageComponent,
SafeHtmlPipe,
DecimalPipe,
DatePipe,
ReadTimePipe
],
templateUrl: './edit-volume-modal.component.html',
styleUrl: './edit-volume-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditVolumeModalComponent implements OnInit {
public readonly modal = inject(NgbActiveModal);
public readonly utilityService = inject(UtilityService);
public readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly cdRef = inject(ChangeDetectorRef);
public readonly accountService = inject(AccountService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly downloadService = inject(DownloadService);
private readonly volumeService = inject(VolumeService);
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly Action = Action;
protected readonly PersonRole = PersonRole;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) volume!: Volume;
@Input({required: true}) libraryType!: LibraryType;
@Input({required: true}) libraryId!: number;
@Input({required: true}) seriesId!: number;
activeId = TabID.CoverImage;
editForm: FormGroup = new FormGroup({});
selectedCover: string = '';
coverImageReset = false;
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList);
/**
* A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified
*/
initVolume!: Volume;
imageUrls: Array<string> = [];
size: number = 0;
files: Array<MangaFile> = [];
ngOnInit() {
this.initVolume = Object.assign({}, this.volume);
this.imageUrls.push(this.imageService.getVolumeCoverImage(this.volume.id));
this.files = this.volume.chapters.flatMap(c => c.files);
this.size = this.files.reduce((sum, v) => sum + v.bytes, 0);
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
this.editForm.addControl('coverImageLocked', new FormControl(this.volume.coverImageLocked, []));
}
close() {
this.modal.dismiss();
}
save() {
const model = this.editForm.value;
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
const apis = [];
if (selectedIndex > 0 || this.coverImageReset) {
apis.push(this.uploadService.updateVolumeCoverImage(model.id, this.selectedCover, !this.coverImageReset));
}
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, volume: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: false, isDeleted: false} as EditVolumeModalCloseResult);
});
}
async runTask(action: ActionItem<Volume>) {
switch (action.action) {
case Action.MarkAsRead:
this.actionService.markVolumeAsRead(this.seriesId, this.volume, (p) => {
this.volume.pagesRead = p.pagesRead;
this.cdRef.markForCheck();
});
break;
case Action.MarkAsUnread:
this.actionService.markVolumeAsUnread(this.seriesId, this.volume, (p) => {
this.volume.pagesRead = 0;
this.cdRef.markForCheck();
});
break;
case Action.Delete:
await this.actionService.deleteVolume(this.volume.id, (b) => {
if (!b) return;
this.modal.close({success: b, volume: this.volume, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditVolumeModalCloseResult);
});
break;
case Action.Download:
this.downloadService.download('volume', this.volume);
break;
}
}
updateSelectedIndex(index: number) {
this.editForm.patchValue({
coverImageIndex: index
});
this.cdRef.markForCheck();
}
updateSelectedImage(url: string) {
this.selectedCover = url;
this.cdRef.markForCheck();
}
handleReset() {
this.coverImageReset = true;
this.editForm.patchValue({
coverImageLocked: false
});
this.cdRef.markForCheck();
}
}

View file

@ -1,36 +1,31 @@
<ng-container *transloco="let t; read:'review-card'">
<div class="card review-card clickable mb-3" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div class="col-md-2 d-none d-md-block p-2">
@if (isMyReview) {
<div class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" [title]="t('your-review')"></i>
<span class="visually-hidden">{{t('your-review')}}</span>
</div>
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="40" height="40" alt="">
} @else {
<img class="me-2" [ngSrc]="review.provider | providerImage" width="40" height="40" alt="">
}
</div>
<div class="col-md-10">
<div class="card-body">
<div class="card-body p-2">
<!--
<h6 class="card-title">
{{review.isExternal ? t('external-review') : t('local-review')}}
</h6>
</h6>-->
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="150" [showToggle]="false"></app-read-more>
</p>
</div>
</div>
<div class="card-footer bg-transparent text-muted">
<div class="card-footer bg-transparent text-muted p-2">
<div>
@if (isMyReview) {
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt="">
{{review.username}}
} @else {
<img class="me-2" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
}
{{(isMyReview ? '' : review.username | defaultValue:'')}}
</div>
@if (review.isExternal){

View file

@ -1,7 +1,7 @@
.review-card {
max-width: 320px;
max-height: 160px;
height: 160px;
max-height: 130px;
height: 130px;
width: 320px;
}
@ -33,8 +33,6 @@
}
.card-text.no-images {
min-height: 63px;
max-height: 63px;
text-overflow: ellipsis;
overflow: hidden;
}
@ -49,10 +47,16 @@
max-width: 319px;
justify-content: space-between;
margin: 0 auto;
padding: .5rem 0;
& > * {
margin: 0 5px;
display: inline-flex;
}
}
.card-body {
display: block;
visibility: visible;
min-height: 93.5px;
max-height: 93.5px;
}

View file

@ -51,9 +51,9 @@
<div class="mt-3">
<app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
<ng-template #itemTemplate let-item>
<div class="card mb-3" style="max-width: 180px;">
<div class="card mb-3">
<div class="row g-0">
<div class="col-md-4">
<div class="col-md-3">
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
</ng-container>
@ -61,7 +61,7 @@
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
</ng-template>
</div>
<div class="col-md-8">
<div class="col-md-9">
<div class="card-body">
<h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
@ -109,7 +109,7 @@
<div class="mt-3">
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
<ng-template #itemTemplate let-item>
<div class="card mb-3" style="max-width: 180px;">
<div class="card mb-3">
<div class="row g-0">
<div class="col-md-4">
<i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i>