diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs index 13dd6fe39..dea73c6af 100644 --- a/API/DTOs/Reader/AnnotationDto.cs +++ b/API/DTOs/Reader/AnnotationDto.cs @@ -67,6 +67,11 @@ public sealed record AnnotationDto public required int OwnerUserId { get; set; } public string OwnerUsername { get; set; } + /// + /// The age rating of the series this annotation is linked to + /// + /// Not required when creating/updating an annotation, this is added in flight + public AgeRating AgeRating { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index a50d0a208..f65a04aa9 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -328,8 +328,8 @@ public class UserRepository : IUserRepository public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true) { - var query = _context.AppUser - .Includes(includeFlags); + var query = _context.AppUser.Includes(includeFlags); + if (track) { return await query.ToListAsync(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index ecfcd5c97..6c1c4849c 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -394,12 +394,14 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)) .ForMember(dest => dest.SeriesName, opt => opt.MapFrom(src => src.Series.Name)) - .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)); + .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)) + .ForMember(dest => dest.AgeRating, opt => opt.MapFrom(src => src.Series.Metadata.AgeRating)); CreateMap() .ForMember(dest => dest.SeriesName, opt => opt.MapFrom(src => src.Series.Name)) .ForMember(dest => dest.VolumeName, opt => opt.MapFrom(src => src.Chapter.Volume.Name)) - .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)); + .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)) + .ForMember(dest => dest.UserId, opt => opt.MapFrom(src => src.AppUserId)); CreateMap(); } diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index 0b2b308c9..3f136e5cd 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -11,6 +11,12 @@ namespace API.Middleware; public class ExceptionMiddleware(RequestDelegate next, ILogger logger) { + + private static readonly JsonSerializerOptions ExceptionJsonSerializeOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + public async Task InvokeAsync(HttpContext context) { try @@ -27,13 +33,7 @@ public class ExceptionMiddleware(RequestDelegate next, ILogger u.Id, u => u); - // Get all annotations for the user with related data IList annotations; if (annotationIds == null) @@ -172,6 +167,15 @@ public class AnnotationService( annotations = await unitOfWork.AnnotationRepository.GetFullAnnotations(userId, annotationIds); } + var userIds = annotations.Select(a => a.UserId) + .Distinct() + .ToList(); + + // Get users with preferences for highlight colors + var users = (await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences, false)) + .Where(u => userIds.Contains(u.Id)) + .ToDictionary(u => u.Id, u => u); + // Get settings for hostname var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var hostname = !string.IsNullOrWhiteSpace(settings.HostName) ? settings.HostName : "http://localhost:5000"; diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 555445bae..2db884ac1 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,10 +1,10 @@ import {inject, Injectable} from '@angular/core'; -import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; -import { Observable, throwError } from 'rxjs'; -import { Router } from '@angular/router'; -import { ToastrService } from 'ngx-toastr'; -import { catchError } from 'rxjs/operators'; -import { AccountService } from '../_services/account.service'; +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {Observable, throwError} from 'rxjs'; +import {Router} from '@angular/router'; +import {ToastrService} from 'ngx-toastr'; +import {catchError} from 'rxjs/operators'; +import {AccountService} from '../_services/account.service'; import {translate, TranslocoService} from "@jsverse/transloco"; import {AuthGuard} from "../_guards/auth.guard"; import {APP_BASE_HREF} from "@angular/common"; @@ -40,6 +40,9 @@ export class ErrorInterceptor implements HttpInterceptor { case 500: this.handleServerException(error); break; + case 413: + this.handlePayloadTooLargeException(error); + break; default: // Don't throw multiple Something unexpected went wrong const genericError = translate('errors.generic'); @@ -100,6 +103,10 @@ export class ErrorInterceptor implements HttpInterceptor { this.toast('errors.not-found'); } + private handlePayloadTooLargeException(error: any) { + this.toast('errors.upload-too-large'); + } + private handleServerException(error: any) { const err = error.error; if (err.hasOwnProperty('message') && err.message.trim() !== '') { diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 9f6ac5b3f..92f595789 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -16,6 +16,7 @@ import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; import {LicenseService} from "./license.service"; import {LocalizationService} from "./localization.service"; +import {Annotation} from "../book-reader/_models/annotations/annotation"; export enum Role { Admin = 'Admin', @@ -194,6 +195,46 @@ export class AccountService { return this.httpClient.get(this.baseUrl + 'account/roles'); } + /** + * Should likes be displayed for the given annotation + * @param annotation + */ + showAnnotationLikes(annotation: Annotation) { + const user = this.currentUserSignal(); + if (!user) return false; + + const shareAnnotations = user.preferences.socialPreferences.shareAnnotations; + return this.isSocialFeatureEnabled(shareAnnotations, annotation.libraryId, annotation.ageRating); + } + + /** + * Checks if the given social feature is enabled in a library with associated age rating on the entity + * @param feature + * @param activeLibrary + * @param ageRating + * @private + */ + private isSocialFeatureEnabled(feature: boolean, activeLibrary: number, ageRating: AgeRating) { + const user = this.currentUserSignal(); + if (!user || !feature) return false; + + const socialPreferences = user.preferences.socialPreferences; + + const libraryAllowed = socialPreferences.socialLibraries.length === 0 || + socialPreferences.socialLibraries.includes(activeLibrary); + + if (!libraryAllowed || socialPreferences.socialMaxAgeRating === AgeRating.NotApplicable) { + return libraryAllowed; + } + + if (socialPreferences.socialIncludeUnknowns) { + return socialPreferences.socialMaxAgeRating >= ageRating; + } + + return socialPreferences.socialMaxAgeRating >= ageRating && ageRating !== AgeRating.Unknown; + + } + login(model: {username: string, password: string, apiKey?: string}) { diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html index d7253e4bc..002f8fbbb 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html @@ -1,5 +1,5 @@ - @if (visible()) { + @if (visible() && accountService.showAnnotationLikes(annotation())) { a.id === annotation.id)[0]); }); } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 92a5ccfe9..726012def 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -41,7 +41,7 @@ (mousedown)="mouseDown($event)" > @@ -87,7 +87,7 @@ @if (!this.adhocPageHistory.isEmpty()) { - + } @@ -107,7 +107,7 @@ - + diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 7f6bd2a7d..e5b5dc868 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -392,6 +392,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { protected verticalBookContentWidth!: Signal; protected virtualizedPageNum!: Signal; protected virtualizedMaxPages!: Signal; + protected bookContentPaddingBottom = computed(() => { + const layoutMode = this.layoutMode(); + if (layoutMode !== BookPageLayoutMode.Default) return '0px'; + return '40px'; + }); pageWidthForPagination = computed(() => { const layoutMode = this.layoutMode(); diff --git a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts index 939d8695b..3ee1c323a 100644 --- a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts +++ b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts @@ -1,3 +1,4 @@ +import {AgeRating} from "../../../_models/metadata/age-rating"; export interface Annotation { id: number; @@ -28,5 +29,5 @@ export interface Annotation { seriesName: string; libraryName: string; - + ageRating: AgeRating; } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 5cb2ffbd7..1aef14bf0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2715,6 +2715,7 @@ "delete-font-in-use": "Font is currently in use by at least one user, cannot delete", "font-manual-upload": "There was an issue creating Font from manual upload", "font-already-in-use": "Font already exists by that name", + "upload-too-large": "The file is too large for upload, select a smaller image and try again.", "import-fields": { "non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file", "non-unique-fields": "Field mappings do not have a unique id, please correct your import file"