Extract review tab & add to chapter

This commit is contained in:
Amelia 2025-04-25 21:48:54 +02:00
parent a152f16cf6
commit a3e04f3bc1
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
13 changed files with 220 additions and 124 deletions

View file

@ -15,8 +15,8 @@ import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.c
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import { import {
ReviewSeriesModalCloseEvent, ReviewSeriesModalCloseEvent,
ReviewSeriesModalComponent ReviewModalComponent
} from "../review-series-modal/review-series-modal.component"; } from "../review-modal/review-modal.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe"; import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
@ -53,7 +53,7 @@ export class ReviewCardComponent implements OnInit {
showModal() { showModal() {
let component; let component;
if (this.isMyReview) { if (this.isMyReview) {
component = ReviewSeriesModalComponent; component = ReviewModalComponent;
} else { } else {
component = ReviewCardModalComponent; component = ReviewCardModalComponent;
} }

View file

@ -3,6 +3,8 @@ import {ScrobbleProvider} from "../../_services/scrobbling.service";
export interface UserReview { export interface UserReview {
seriesId: number; seriesId: number;
libraryId: number; libraryId: number;
volumeId?: number;
chapterId?: number;
score: number; score: number;
username: string; username: string;
body: string; body: string;

View file

@ -6,6 +6,7 @@ import {UserReview} from "../review-card/user-review";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ConfirmService} from "../../shared/confirm.service"; import {ConfirmService} from "../../shared/confirm.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {ChapterService} from "../../_services/chapter.service";
export enum ReviewSeriesModalCloseAction { export enum ReviewSeriesModalCloseAction {
Create, Create,
@ -22,20 +23,22 @@ export interface ReviewSeriesModalCloseEvent {
@Component({ @Component({
selector: 'app-review-series-modal', selector: 'app-review-series-modal',
imports: [ReactiveFormsModule, TranslocoDirective], imports: [ReactiveFormsModule, TranslocoDirective],
templateUrl: './review-series-modal.component.html', templateUrl: './review-modal.component.html',
styleUrls: ['./review-series-modal.component.scss'], styleUrls: ['./review-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ReviewSeriesModalComponent implements OnInit { export class ReviewModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal); protected readonly modal = inject(NgbActiveModal);
private readonly seriesService = inject(SeriesService); private readonly seriesService = inject(SeriesService);
private readonly chapterService = inject(ChapterService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService); private readonly confirmService = inject(ConfirmService);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
protected readonly minLength = 5; protected readonly minLength = 5;
@Input({required: true}) review!: UserReview; @Input({required: true}) review!: UserReview;
@Input() reviewLocation: 'series' | 'chapter' = 'series';
reviewGroup!: FormGroup; reviewGroup!: FormGroup;
ngOnInit(): void { ngOnInit(): void {
@ -51,18 +54,26 @@ export class ReviewSeriesModalComponent implements OnInit {
async delete() { async delete() {
if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return; if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return;
this.seriesService.deleteReview(this.review.seriesId).subscribe(() => {
this.toastr.success(translate('toasts.review-deleted')); if (this.reviewLocation === 'series') {
this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete}); this.seriesService.deleteReview(this.review.seriesId).subscribe(() => {
}); this.toastr.success(translate('toasts.review-deleted'));
this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete});
});
}
} }
save() { save() {
const model = this.reviewGroup.value; const model = this.reviewGroup.value;
if (model.reviewBody.length < this.minLength) { if (model.reviewBody.length < this.minLength) {
return; return;
} }
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit}); if (this.reviewLocation === 'series') {
}); this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
});
}
} }
} }

View file

@ -0,0 +1,17 @@
<div class="mb-3" *transloco="let t;prefix:'reviews'">
<app-carousel-reel [items]="userReviews" [alwaysShow]="true" [title]="t('user-reviews-local')"
iconClasses="fa-solid fa-{{getUserReviews().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3" *transloco="let t;prefix:'reviews'">
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>

View file

@ -0,0 +1,106 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnInit} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {ReviewCardComponent} from "../review-card/review-card.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {UserReview} from "../review-card/user-review";
import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service";
import {
ReviewModalComponent, ReviewSeriesModalCloseAction,
ReviewSeriesModalCloseEvent
} from "../review-modal/review-modal.component";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {Series} from "../../_models/series";
import {Volume} from "../../_models/volume";
import {Chapter} from "../../_models/chapter";
@Component({
selector: 'app-reviews',
imports: [
CarouselReelComponent,
ReviewCardComponent,
TranslocoDirective
],
templateUrl: './reviews.component.html',
styleUrl: './reviews.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReviewsComponent {
@Input({required: true}) userReviews!: Array<UserReview>;
@Input({required: true}) plusReviews!: Array<UserReview>;
@Input({required: true}) series!: Series;
@Input() volumeId: number | undefined;
@Input() chapter: Chapter | undefined;
@Input() reviewLocation: 'series' | 'chapter' = 'series';
user: User | undefined;
constructor(
private accountService: AccountService,
private modalService: NgbModal,
private cdRef: ChangeDetectorRef) {
this.accountService.currentUser$.subscribe(user => {
if (user) {
this.user = user;
}
});
}
openReviewModal() {
const userReview = this.getUserReviews();
const modalRef = this.modalService.open(ReviewModalComponent, DefaultModalOptions);
modalRef.componentInstance.reviewLocation = this.reviewLocation;
if (userReview.length > 0) {
modalRef.componentInstance.review = userReview[0];
} else {
modalRef.componentInstance.review = {
seriesId: this.series.id,
volumeId: this.volumeId,
chapterId: this.chapter?.id,
tagline: '',
body: ''
};
}
modalRef.closed.subscribe((closeResult) => {
this.updateOrDeleteReview(closeResult);
});
}
updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) {
if (closeResult.action === ReviewSeriesModalCloseAction.Close) return;
const index = this.userReviews.findIndex(r => r.username === closeResult.review!.username);
if (closeResult.action === ReviewSeriesModalCloseAction.Edit) {
if (index === -1 ) {
this.userReviews = [closeResult.review, ...this.userReviews];
this.cdRef.markForCheck();
return;
}
this.userReviews[index] = closeResult.review;
this.cdRef.markForCheck();
return;
}
if (closeResult.action === ReviewSeriesModalCloseAction.Delete) {
this.userReviews = [...this.userReviews.filter(r => r.username !== closeResult.review!.username)];
this.cdRef.markForCheck();
return;
}
}
getUserReviews() {
if (!this.user) {
return [];
}
return this.userReviews.filter(r => r.username === this.user?.username && !r.isExternal);
}
}

View file

@ -175,6 +175,20 @@
</li> </li>
} }
<li [ngbNavItem]="TabID.Reviews">
<a ngbNavLink>
{{t(TabID.Reviews)}}
<span class="badge rounded-pill text-bg-secondary">{{userReviews.length + plusReviews.length}}</span>
</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Reviews; prefetch on idle) {
<app-reviews [userReviews]="userReviews" [plusReviews]="plusReviews"
[series]="series" [chapter]="chapter" [volumeId]="chapter.volumeId"
reviewLocation="chapter" />
}
</ng-template>
</li>
@if(readingLists.length > 0) { @if(readingLists.length > 0) {
<li [ngbNavItem]="TabID.Related"> <li [ngbNavItem]="TabID.Related">
<a ngbNavLink>{{t('related-tab')}}</a> <a ngbNavLink>{{t('related-tab')}}</a>

View file

@ -65,6 +65,12 @@ import {ActionService} from "../_services/action.service";
import {DefaultDatePipe} from "../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component"; import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options"; import {DefaultModalOptions} from "../_models/default-modal-options";
import {UserReview} from "../_single-module/review-card/user-review";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {ReviewCardComponent} from "../_single-module/review-card/review-card.component";
import {User} from "../_models/user";
import {ReviewModalComponent} from "../_single-module/review-modal/review-modal.component";
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
enum TabID { enum TabID {
Related = 'related-tab', Related = 'related-tab',
@ -74,36 +80,39 @@ enum TabID {
@Component({ @Component({
selector: 'app-chapter-detail', selector: 'app-chapter-detail',
imports: [ imports: [
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
LoadingComponent, LoadingComponent,
NgbDropdown, NgbDropdown,
NgbDropdownItem, NgbDropdownItem,
NgbDropdownMenu, NgbDropdownMenu,
NgbDropdownToggle, NgbDropdownToggle,
NgbNav, NgbNav,
NgbNavContent, NgbNavContent,
NgbNavLink, NgbNavLink,
NgbTooltip, NgbTooltip,
VirtualScrollerModule, VirtualScrollerModule,
NgStyle, NgStyle,
NgClass, NgClass,
TranslocoDirective, TranslocoDirective,
ReadMoreComponent, ReadMoreComponent,
NgbNavItem, NgbNavItem,
NgbNavOutlet, NgbNavOutlet,
DetailsTabComponent, DetailsTabComponent,
RouterLink, RouterLink,
EntityTitleComponent, EntityTitleComponent,
RelatedTabComponent, RelatedTabComponent,
BadgeExpanderComponent, BadgeExpanderComponent,
MetadataDetailRowComponent, MetadataDetailRowComponent,
DownloadButtonComponent, DownloadButtonComponent,
DatePipe, DatePipe,
DefaultDatePipe, DefaultDatePipe,
CoverImageComponent CoverImageComponent,
], CarouselReelComponent,
ReviewCardComponent,
ReviewsComponent
],
templateUrl: './chapter-detail.component.html', templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss', styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -151,6 +160,8 @@ export class ChapterDetailComponent implements OnInit {
series: Series | null = null; series: Series | null = null;
libraryType: LibraryType | null = null; libraryType: LibraryType | null = null;
hasReadingProgress = false; hasReadingProgress = false;
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
weblinks: Array<string> = []; weblinks: Array<string> = [];
activeTabId = TabID.Details; activeTabId = TabID.Details;
/** /**

View file

@ -315,25 +315,8 @@
</a> </a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@defer (when activeTabId === TabID.Reviews; prefetch on idle) { @defer (when activeTabId === TabID.Reviews; prefetch on idle) {
<div class="mb-3"> <app-reviews [userReviews]="reviews" [plusReviews]="plusReviews" [series]="series" />
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-local')"
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
} }
</ng-template> </ng-template>
</li> </li>

View file

@ -64,8 +64,8 @@ import {SeriesService} from 'src/app/_services/series.service';
import { import {
ReviewSeriesModalCloseAction, ReviewSeriesModalCloseAction,
ReviewSeriesModalCloseEvent, ReviewSeriesModalCloseEvent,
ReviewSeriesModalComponent ReviewModalComponent
} from '../../../_single-module/review-series-modal/review-series-modal.component'; } from '../../../_single-module/review-modal/review-modal.component';
import {PageLayoutMode} from 'src/app/_models/page-layout-mode'; import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {UserReview} from "../../../_single-module/review-card/user-review"; import {UserReview} from "../../../_single-module/review-card/user-review";
@ -116,6 +116,7 @@ import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {LicenseService} from "../../../_services/license.service"; import {LicenseService} from "../../../_services/license.service";
import {PageBookmark} from "../../../_models/readers/page-bookmark"; import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event"; import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event";
import {ReviewsComponent} from "../../../_single-module/reviews/reviews.component";
enum TabID { enum TabID {
@ -140,14 +141,14 @@ interface StoryLineItem {
templateUrl: './series-detail.component.html', templateUrl: './series-detail.component.html',
styleUrls: ['./series-detail.component.scss'], styleUrls: ['./series-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle, imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle,
NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbDropdownItem, BulkOperationsComponent,
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent, TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent, NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent,
VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent, VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent,
PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent] PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent, ReviewsComponent]
}) })
export class SeriesDetailComponent implements OnInit, AfterContentChecked { export class SeriesDetailComponent implements OnInit, AfterContentChecked {
@ -1137,56 +1138,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}); });
} }
getUserReview() {
return this.reviews.filter(r => r.username === this.user?.username && !r.isExternal);
}
openReviewModal() {
const userReview = this.getUserReview();
const modalRef = this.modalService.open(ReviewSeriesModalComponent, DefaultModalOptions);
modalRef.componentInstance.series = this.series;
if (userReview.length > 0) {
modalRef.componentInstance.review = userReview[0];
} else {
modalRef.componentInstance.review = {
seriesId: this.series.id,
tagline: '',
body: ''
};
}
modalRef.closed.subscribe((closeResult) => {
this.updateOrDeleteReview(closeResult);
});
}
updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) {
if (closeResult.action === ReviewSeriesModalCloseAction.Close) return;
const index = this.reviews.findIndex(r => r.username === closeResult.review!.username);
if (closeResult.action === ReviewSeriesModalCloseAction.Edit) {
if (index === -1 ) {
// A new series was added:
this.reviews = [closeResult.review, ...this.reviews];
this.cdRef.markForCheck();
return;
}
// An edit occurred
this.reviews[index] = closeResult.review;
this.cdRef.markForCheck();
return;
}
if (closeResult.action === ReviewSeriesModalCloseAction.Delete) {
// An edit occurred
this.reviews = [...this.reviews.filter(r => r.username !== closeResult.review!.username)];
this.cdRef.markForCheck();
return;
}
}
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {

View file

@ -67,6 +67,10 @@
"spoiler": { "spoiler": {
"click-to-show": "Spoiler, click to show" "click-to-show": "Spoiler, click to show"
}, },
"reviews": {
"user-reviews-local": "Local Reviews",
"user-reviews-plus": "External Reviews"
},
"review-series-modal": { "review-series-modal": {
"title": "Edit Review", "title": "Edit Review",
@ -958,9 +962,6 @@
"no-chapters": "There are no chapters to this volume. Cannot read.", "no-chapters": "There are no chapters to this volume. Cannot read.",
"cover-change": "It can take up to a minute for your browser to refresh the image. Until then, the old image may be shown on some pages.", "cover-change": "It can take up to a minute for your browser to refresh the image. Until then, the old image may be shown on some pages.",
"user-reviews-local": "Local Reviews",
"user-reviews-plus": "External Reviews",
"writers-title": "{{metadata-fields.writers-title}}", "writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}", "cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}", "characters-title": "{{metadata-fields.characters-title}}",