Co-authored-by: Hippari <iamtimscampi@gmail.com>
Co-authored-by: Gavin Mogan <github@gavinmogan.com>
This commit is contained in:
Joe Milazzo 2024-12-09 13:06:28 -06:00 committed by GitHub
parent 0407d75d91
commit bfbcb4b741
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 551 additions and 364 deletions

View File

@ -137,6 +137,7 @@ jobs:
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
if: ${{ github.repository_owner == 'Kareadita' }}
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
@ -155,20 +156,33 @@ jobs:
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: docker_meta_nightly
uses: docker/metadata-action@v5
with:
tags: |
type=raw,value=nightly
type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }}
images: |
name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }}
name=ghcr.io/${{ github.repository }}
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true push: true
tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} tags: ${{ steps.docker_meta_nightly.outputs.tags }}
labels: ${{ steps.docker_meta_nightly.outputs.labels }}
- name: Image digest - name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }} run: echo ${{ steps.docker_build.outputs.digest }}
- name: Notify Discord - name: Notify Discord
uses: rjstone/discord-webhook-notify@v1 uses: rjstone/discord-webhook-notify@v1
if: ${{ github.repository_owner == 'Kareadita' }}
with: with:
severity: info severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}

View File

@ -114,6 +114,7 @@ jobs:
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
if: ${{ github.repository_owner == 'Kareadita' }}
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
@ -132,23 +133,47 @@ jobs:
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: docker_meta_stable
uses: docker/metadata-action@v5
with:
tags: |
type=raw,value=latest
type=raw,value=${{ steps.parse-version.outputs.VERSION }}
images: |
name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }}
name=ghcr.io/${{ github.repository }}
- name: Build and push stable - name: Build and push stable
id: docker_build_stable id: docker_build_stable
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true push: true
tags: jvmilazz0/kavita:latest, jvmilazz0/kavita:${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:latest, ghcr.io/kareadita/kavita:${{ steps.parse-version.outputs.VERSION }} tags: ${{ steps.docker_meta_stable.outputs.tags }}
labels: ${{ steps.docker_meta_stable.outputs.labels }}
- name: Extract metadata (tags, labels) for Docker
id: docker_meta_nightly
uses: docker/metadata-action@v5
with:
tags: |
type=raw,value=nightly
type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }}
images: |
name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }}
name=ghcr.io/${{ github.repository }}
- name: Build and push nightly - name: Build and push nightly
id: docker_build_nightly id: docker_build_nightly
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true push: true
tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} tags: ${{ steps.docker_meta_nightly.outputs.tags }}
labels: ${{ steps.docker_meta_nightly.outputs.labels }}
- name: Image digest - name: Image digest
run: echo ${{ steps.docker_build_stable.outputs.digest }} run: echo ${{ steps.docker_build_stable.outputs.digest }}

View File

@ -123,14 +123,14 @@ public class QueryableExtensionsTests
[Theory] [Theory]
[InlineData(true, 2)] [InlineData(true, 2)]
[InlineData(false, 1)] [InlineData(false, 2)]
public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount)
{ {
// Arrange // Arrange
var items = new List<Person> var items = new List<Person>
{ {
CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen),
CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), // 2 series on this person, restrict will still allow access
CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus)
}; };
@ -144,7 +144,7 @@ public class QueryableExtensionsTests
var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction); var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction);
// Assert // Assert
Assert.Equal(expectedCount, filtered.Count()); Assert.Equal(expectedPeopleCount, filtered.Count());
} }
private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings) private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings)

View File

@ -509,6 +509,21 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Update(user); _unitOfWork.UserRepository.Update(user);
} }
// Check if email is changing for a non-admin user
var isUpdatingAnotherAccount = user.Id != adminUser.Id;
if (isUpdatingAnotherAccount && !string.IsNullOrEmpty(dto.Email) && user.Email != dto.Email)
{
// Validate username change
var errors = await _accountService.ValidateEmail(dto.Email);
if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "email-taken"));
user.Email = dto.Email;
user.EmailConfirmed = true; // When an admin performs the flow, we assume the email address is able to receive data
await _userManager.UpdateNormalizedEmailAsync(user);
_unitOfWork.UserRepository.Update(user);
}
// Update roles // Update roles
var existingRoles = await _userManager.GetRolesAsync(user); var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -612,8 +627,7 @@ public class AccountController : BaseApiController
if (adminUser == null) return Unauthorized(await _localizationService.Translate(userId, "permission-denied")); if (adminUser == null) return Unauthorized(await _localizationService.Translate(userId, "permission-denied"));
dto.Email = dto.Email.Trim(); dto.Email = dto.Email.Trim();
if (string.IsNullOrEmpty(dto.Email)) if (string.IsNullOrEmpty(dto.Email)) return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
@ -623,7 +637,7 @@ public class AccountController : BaseApiController
{ {
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) if (await _userManager.IsEmailConfirmedAsync(invitedUser!))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser.UserName)); return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName));
return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited"));
} }

View File

@ -18,4 +18,8 @@ public record UpdateUserDto
/// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary> /// </summary>
public AgeRestrictionDto AgeRestriction { get; init; } = default!; public AgeRestrictionDto AgeRestriction { get; init; } = default!;
/// <summary>
/// Email of the user
/// </summary>
public string? Email { get; set; } = default!;
} }

View File

@ -101,12 +101,15 @@ public static class RestrictByAgeExtensions
if (restriction.IncludeUnknowns) if (restriction.IncludeUnknowns)
{ {
return queryable.Where(c => c.SeriesMetadataPeople.All(sm => return queryable.Where(c =>
sm.SeriesMetadata.AgeRating <= restriction.AgeRating)); c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating) ||
c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating));
} }
return queryable.Where(c => c.SeriesMetadataPeople.All(sm => return queryable.Where(c =>
sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating > AgeRating.Unknown)); c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) ||
c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating && cp.Chapter.AgeRating != AgeRating.Unknown)
);
} }
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRestriction restriction) public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRestriction restriction)

View File

@ -18,6 +18,7 @@
"age-restriction-update": "There was an error updating the age restriction", "age-restriction-update": "There was an error updating the age restriction",
"no-user": "User does not exist", "no-user": "User does not exist",
"username-taken": "Username already taken", "username-taken": "Username already taken",
"email-taken": "Email already in use",
"user-already-confirmed": "User is already confirmed", "user-already-confirmed": "User is already confirmed",
"generic-user-update": "There was an exception when updating the user", "generic-user-update": "There was an exception when updating the user",
"manual-setup-fail": "Manual setup is unable to be completed. Please cancel and recreate the invite", "manual-setup-fail": "Manual setup is unable to be completed. Please cancel and recreate the invite",

View File

@ -95,12 +95,12 @@ public class AccountService : IAccountService
public async Task<IEnumerable<ApiException>> ValidateEmail(string email) public async Task<IEnumerable<ApiException>> ValidateEmail(string email)
{ {
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null) return Array.Empty<ApiException>(); if (user == null) return [];
return new List<ApiException>() return
{ [
new ApiException(400, "Email is already registered") new ApiException(400, "Email is already registered")
}; ];
} }
/// <summary> /// <summary>

View File

@ -14,6 +14,7 @@ using API.DTOs.Stats.V3;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using Flurl.Http; using Flurl.Http;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
@ -231,11 +232,12 @@ public class StatsService : IStatsService
{ {
// If first time flow, just return 0 // If first time flow, just return 0
if (!await _context.Chapter.AnyAsync()) return 0; if (!await _context.Chapter.AnyAsync()) return 0;
return await _context.Series return await _context.Series
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()
.MaxAsync(s => s.Volumes! .MaxAsync(s => s.Volumes!
.Where(v => v.MinNumber == 0) .Where(v => v.MinNumber == Parser.LooseLeafVolumeNumber)
.SelectMany(v => v.Chapters!) .SelectMany(v => v.Chapters!)
.Count()); .Count());
} }
@ -262,13 +264,13 @@ public class StatsService : IStatsService
dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(); dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary();
dto.MaxVolumesInASeries = await MaxVolumesInASeries(); dto.MaxVolumesInASeries = await MaxVolumesInASeries();
dto.MaxChaptersInASeries = await MaxChaptersInASeries(); dto.MaxChaptersInASeries = await MaxChaptersInASeries();
dto.TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(); dto.TotalFiles = await _context.MangaFile.CountAsync();
dto.TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(); dto.TotalGenres = await _context.Genre.CountAsync();
dto.TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(); dto.TotalPeople = await _context.Person.CountAsync();
dto.TotalSeries = await _unitOfWork.SeriesRepository.GetCountAsync(); dto.TotalSeries = await _context.Series.CountAsync();
dto.TotalLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(); dto.TotalLibraries = await _context.Library.CountAsync();
dto.NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(); dto.NumberOfCollections = await _context.AppUserCollection.CountAsync();
dto.NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(); dto.NumberOfReadingLists = await _context.ReadingList.CountAsync();
try try
{ {
@ -314,6 +316,7 @@ public class StatsService : IStatsService
libDto.UsingFolderWatching = library.FolderWatching; libDto.UsingFolderWatching = library.FolderWatching;
libDto.CreateCollectionsFromMetadata = library.ManageCollections; libDto.CreateCollectionsFromMetadata = library.ManageCollections;
libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; libDto.CreateReadingListsFromMetadata = library.ManageReadingLists;
libDto.LibraryType = library.Type;
dto.Libraries.Add(libDto); dto.Libraries.Add(libDto);
} }

View File

@ -0,0 +1,27 @@
import {Directive, EventEmitter, HostListener, Output} from '@angular/core';
@Directive({
selector: '[appDblClick]',
standalone: true
})
export class DblClickDirective {
@Output() doubleClick = new EventEmitter<Event>();
private lastTapTime = 0;
private tapTimeout = 300; // Time threshold for a double tap (in milliseconds)
@HostListener('click', ['$event'])
handleClick(event: Event): void {
event.stopPropagation();
event.preventDefault();
const currentTime = new Date().getTime();
if (currentTime - this.lastTapTime < this.tapTimeout) {
// Detected a double click/tap
this.doubleClick.emit(event);
}
this.lastTapTime = currentTime;
}
}

View File

@ -26,7 +26,7 @@ export class PersonService {
} }
get(name: string) { get(name: string) {
return this.httpClient.get<Person>(this.baseUrl + `person?name=${name}`); return this.httpClient.get<Person | null>(this.baseUrl + `person?name=${name}`);
} }
getRolesForPerson(personId: number) { getRolesForPerson(personId: number) {

View File

@ -1,14 +1,14 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
@if (actions.length > 0) { @if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" <button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)"> (click)="openMobileActionableMenu($event)">
{{label}} {{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i> <i class="fa {{iconClass}}" aria-hidden="true"></i>
</button> </button>
} @else { } @else {
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle <button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)"> (click)="preventEvent($event)">
{{label}} {{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i> <i class="fa {{iconClass}}" aria-hidden="true"></i>

View File

@ -16,7 +16,7 @@
{{review.isExternal ? t('external-review') : t('local-review')}} {{review.isExternal ? t('external-review') : t('local-review')}}
</h6>--> </h6>-->
<p class="card-text no-images"> <p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="150" [showToggle]="false"></app-read-more> <app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="140" [showToggle]="false"></app-read-more>
</p> </p>
</div> </div>
</div> </div>

View File

@ -6,42 +6,54 @@
</button> </button>
</div> </div>
<div class="modal-body scrollable-modal">
<div class="modal-body scrollable-modal">
<form [formGroup]="userForm"> <form [formGroup]="userForm">
<h4>{{t('account-detail-title')}}</h4> <h4>{{t('account-detail-title')}}</h4>
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<div class="col-md-6 col-sm-12 pe-4"> <div class="col-md-6 col-sm-12 pe-4">
@if(userForm.get('username'); as formControl) {
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">{{t('username')}}</label> <label for="username" class="form-label">{{t('username')}}</label>
<input id="username" class="form-control" formControlName="username" type="text" <input id="username" class="form-control" formControlName="username" type="text"
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations"> [class.is-invalid]="formControl.invalid && !formControl.untouched" aria-describedby="username-validations">
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched"> @if(formControl.dirty || !formControl.untouched) {
<div *ngIf="userForm.get('username')?.errors?.required"> <div id="username-validations" class="invalid-feedback">
@if (formControl.errors; as errors) {
<div>
@if (errors.required) {
{{t('required')}} {{t('required')}}
</div> } @else if (errors.pattern) {
<div *ngIf="userForm.get('username')?.errors?.pattern">
{{t('username-pattern', {characters: allowedCharacters})}} {{t('username-pattern', {characters: allowedCharacters})}}
}
</div> </div>
}
</div> </div>
}
</div> </div>
}
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
@if(userForm.get('email'); as formControl) {
<div class="mb-3" style="width:100%"> <div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label> <label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" inputmode="email" type="email" id="email" <input id="email" class="form-control" formControlName="email" type="text"
[class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched" [class.is-invalid]="formControl.invalid && !formControl.untouched" aria-describedby="email-validations">
formControlName="email" aria-describedby="email-validations"> @if(formControl.dirty || !formControl.untouched) {
<div id="email-validations" class="invalid-feedback" <div id="email-validations" class="invalid-feedback">
*ngIf="userForm.dirty || userForm.touched"> @if (formControl.errors; as errors) {
<div *ngIf="userForm.get('email')?.errors?.required"> <div>
@if (errors.required) {
{{t('required')}} {{t('required')}}
</div> } @else if (errors.email) {
<div *ngIf="userForm.get('email')?.errors?.email">
{{t('not-valid-email')}} {{t('not-valid-email')}}
}
</div> </div>
}
</div> </div>
}
</div> </div>
}
</div> </div>
</div> </div>
@ -61,14 +73,16 @@
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()"> <button type="button" class="btn btn-secondary" (click)="close()">
{{t('cancel')}} {{t('cancel')}}
</button> </button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid"> <button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> @if (isSaving) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
<span>{{isSaving ? t('saving') : t('update')}}</span> <span>{{isSaving ? t('saving') : t('update')}}</span>
</button> </button>
</div> </div>

View File

@ -50,7 +50,6 @@ export class EditUserComponent implements OnInit {
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email]));
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
this.userForm.get('email')?.disable();
this.selectedRestriction = this.member.ageRestriction; this.selectedRestriction = this.member.ageRestriction;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }

View File

@ -290,7 +290,7 @@
<div class="row g-0 mt-4 mb-4"> <div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('onDeckUpdateDays'); as formControl) { @if (settingsForm.get('onDeckUpdateDays'); as formControl) {
<app-setting-item [title]="t('on-deck-last-chapter-add-label')" [subtitle]="t('on-deck-last-progress-tooltip')"> <app-setting-item [title]="t('on-deck-last-chapter-add-label')" [subtitle]="t('on-deck-last-chapter-add-tooltip')">
<ng-template #view> <ng-template #view>
{{formControl.value}} {{formControl.value}}
</ng-template> </ng-template>

View File

@ -7,7 +7,7 @@
.companion-bar { .companion-bar {
transition: all var(--side-nav-companion-bar-transistion); transition: all var(--side-nav-companion-bar-transistion);
margin-left: 60px; margin-left: 45px;
overflow-y: hidden; overflow-y: hidden;
overflow-x: hidden; overflow-x: hidden;
height: calc(var(--vh)* 100 - var(--nav-mobile-offset)); height: calc(var(--vh)* 100 - var(--nav-mobile-offset));

View File

@ -85,7 +85,7 @@
</div> </div>
<div class="mt-2 mb-3"> <div class="mt-2 mb-3">
<app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more> <app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div> </div>
<div class="mt-2"> <div class="mt-2">

View File

@ -48,7 +48,7 @@ import {FilterUtilitiesService} from "../shared/_services/filter-utilities.servi
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ReadingList} from "../_models/reading-list"; import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service"; import {ReadingListService} from "../_services/reading-list.service";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import { import {
MetadataDetailRowComponent MetadataDetailRowComponent

View File

@ -41,7 +41,7 @@
<div class="col-md-10 col-xs-8 col-sm-6 mt-2"> <div class="col-md-10 col-xs-8 col-sm-6 mt-2">
@if (summary.length > 0) { @if (summary.length > 0) {
<div class="mb-2"> <div class="mb-2">
<app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more> <app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div> </div>
@if (collectionTag.source !== ScrobbleProvider.Kavita) { @if (collectionTag.source !== ScrobbleProvider.Kavita) {

View File

@ -3,13 +3,14 @@
@if(debugMode) { @if(debugMode) {
<div class="fixed-top overlay"> <div class="fixed-top overlay">
@for(img of cachedImages; track img.src) { @for(img of cachedImages; track img.src) {
<ng-container *ngIf="this.readerService.imageUrlToPageNum(img.src) as imageNum"> @if (this.readerService.imageUrlToPageNum(img.src); as imageNum) {
<span class="me-1" [ngClass]="{'current': imageNum === this.pageNum, 'loaded': img.complete}">{{this.readerService.imageUrlToPageNum(img.src)}}</span> <span class="me-1" [ngClass]="{'current': imageNum === this.pageNum, 'loaded': img.complete}">{{this.readerService.imageUrlToPageNum(img.src)}}</span>
</ng-container> }
} }
</div> </div>
} }
<div class="fixed-top overlay" *ngIf="menuOpen" [@slideFromTop]="menuOpen"> @if (menuOpen) {
<div class="fixed-top overlay" [@slideFromTop]="menuOpen">
<div style="display: flex; margin-top: 5px;"> <div style="display: flex; margin-top: 5px;">
<button class="btn btn-icon" style="height: 100%" [title]="t('back')" (click)="closeReader()"> <button class="btn btn-icon" style="height: 100%" [title]="t('back')" (click)="closeReader()">
<i class="fa fa-arrow-left" aria-hidden="true"></i> <i class="fa fa-arrow-left" aria-hidden="true"></i>
@ -17,9 +18,16 @@
</button> </button>
<div> <div>
<div style="font-weight: bold;">{{title}} <span class="clickable" *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-alt')">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{t('incognito-title')}}</span>)</span></div> <div style="font-weight: bold;">{{title}}
@if (incognitoMode) {
<span class="clickable" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-alt')">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{t('incognito-title')}}</span>)</span>
}
</div>
<div class="subtitle"> <div class="subtitle">
{{subtitle}} <span *ngIf="totalSeriesPages > 0">{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</span> {{subtitle}}
@if (totalSeriesPages > 0) {
<span>{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</span>
}
</div> </div>
</div> </div>
@ -38,12 +46,15 @@
</div> </div>
</div> </div>
</div> </div>
}
<app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading> <app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading>
<div class="reading-area" <div class="reading-area"
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)" ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea> [ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea>
<ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon"> @if (readerMode !== ReaderMode.Webtoon) {
<div (dblclick)="bookmarkPage($event)"> <div (dblclick)="bookmarkPage($event)">
<app-canvas-renderer <app-canvas-renderer
[readerSettings$]="readerSettings$" [readerSettings$]="readerSettings$"
@ -57,24 +68,28 @@
<div class="pagination-area"> <div class="pagination-area">
<div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)" <div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), 'max-height': MaxHeight}"> [ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), 'max-height': MaxHeight}">
<div *ngIf="showClickOverlay"> @if (showClickOverlay) {
<div>
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}" <i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
[title]="t('prev-page-tooltip')" aria-hidden="true"></i> [title]="t('prev-page-tooltip')" aria-hidden="true"></i>
</div> </div>
}
</div> </div>
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)" <div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), [ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'),
'left': 'inherit', 'left': 'inherit',
'right': RightPaginationOffset + 'px', 'right': RightPaginationOffset + 'px',
'max-height': MaxHeight}"> 'max-height': MaxHeight}">
<div *ngIf="showClickOverlay"> @if (showClickOverlay) {
<div>
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}" <i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}"
[title]="t('next-page-tooltip')" aria-hidden="true"></i> [title]="t('next-page-tooltip')" aria-hidden="true"></i>
</div> </div>
}
</div> </div>
</div> </div>
<div (dblclick)="bookmarkPage($event)"> <div appDblClick (doubleClick)="bookmarkPage($event)">
<app-single-renderer [image$]="currentImage$" <app-single-renderer [image$]="currentImage$"
[readerSettings$]="readerSettings$" [readerSettings$]="readerSettings$"
[bookmark$]="showBookmarkEffect$" [bookmark$]="showBookmarkEffect$"
@ -106,11 +121,9 @@
[getPage]="getPageFn"> [getPage]="getPageFn">
</app-double-no-cover-renderer> </app-double-no-cover-renderer>
</div> </div>
} @else {
</ng-container> @if (!isLoading && !inSetup) {
<div class="webtoon-images">
<ng-template #webtoon>
<div class="webtoon-images" *ngIf="!isLoading && !inSetup">
<app-infinite-scroller [pageNum]="pageNum" <app-infinite-scroller [pageNum]="pageNum"
[bufferPages]="5" [bufferPages]="5"
[goToPage]="goToPageEvent" [goToPage]="goToPageEvent"
@ -124,30 +137,32 @@
[readerSettings$]="readerSettings$"> [readerSettings$]="readerSettings$">
</app-infinite-scroller> </app-infinite-scroller>
</div> </div>
</ng-template> }
}
</div> </div>
<div class="fixed-bottom overlay" *ngIf="menuOpen" [@slideFromBottom]="menuOpen"> @if (menuOpen) {
<div class="fixed-bottom overlay" [@slideFromBottom]="menuOpen">
<div class="mb-3" *ngIf="pageOptions !== undefined && pageOptions.ceil !== undefined"> @if (pageOptions !== undefined && pageOptions.ceil !== undefined) {
<div class="mb-3">
<span class="visually-hidden" id="slider-info"></span> <span class="visually-hidden" id="slider-info"></span>
<div class="row g-0"> <div class="row g-0">
<button class="btn btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" [title]="t('prev-chapter-tooltip')"><i class="fa fa-fast-backward" aria-hidden="true"></i></button> <button class="btn btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" [title]="t('prev-chapter-tooltip')"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<button class="btn btn-icon col-2" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" [title]="t('first-page-tooltip')"><i class="fa fa-step-backward" aria-hidden="true"></i></button> <button class="btn btn-icon col-2" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" [title]="t('first-page-tooltip')"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider"> @if (pageOptions.ceil > 0) {
<div class="col custom-slider">
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider> <ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div> </div>
<ng-template #noSlider> } @else {
<div class="col custom-slider"> <div class="col custom-slider">
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider> <ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div> </div>
</ng-template> }
<button class="btn btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" [title]="t('last-page-tooltip')"><i class="fa fa-step-forward" aria-hidden="true"></i></button> <button class="btn btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" [title]="t('last-page-tooltip')"><i class="fa fa-step-forward" aria-hidden="true"></i></button>
<button class="btn btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" [title]="t('next-chapter-tooltip')"><i class="fa fa-fast-forward" aria-hidden="true"></i></button> <button class="btn btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" [title]="t('next-chapter-tooltip')"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
</div> </div>
</div> </div>
}
<div class="row pt-4 ms-2 me-2 mb-2"> <div class="row pt-4 ms-2 me-2 mb-2">
<div class="col"> <div class="col">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')"> <button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')">
@ -174,7 +189,8 @@
</button> </button>
</div> </div>
</div> </div>
<div class="bottom-menu" *ngIf="settingsOpen && generalSettingsForm"> @if (settingsOpen && generalSettingsForm) {
<div class="bottom-menu">
<form [formGroup]="generalSettingsForm"> <form [formGroup]="generalSettingsForm">
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
@ -295,7 +311,10 @@
</div> </div>
</form> </form>
</div> </div>
}
</div> </div>
}
</div> </div>
</ng-container> </ng-container>

View File

@ -13,7 +13,7 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {AsyncPipe, DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgSwitch, NgSwitchCase, PercentPipe} from '@angular/common'; import {AsyncPipe, DOCUMENT, NgClass, NgFor, NgStyle, NgSwitch, NgSwitchCase, PercentPipe} from '@angular/common';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import { import {
BehaviorSubject, BehaviorSubject,
@ -70,6 +70,7 @@ import {SwipeDirective} from '../../../ng-swipe/ng-swipe.directive';
import {LoadingComponent} from '../../../shared/loading/loading.component'; import {LoadingComponent} from '../../../shared/loading/loading.component';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {shareReplay} from "rxjs/operators"; import {shareReplay} from "rxjs/operators";
import {DblClickDirective} from "../../../_directives/dbl-click.directive";
const PREFETCH_PAGES = 10; const PREFETCH_PAGES = 10;
@ -123,10 +124,10 @@ enum KeyDirection {
]) ])
], ],
standalone: true, standalone: true,
imports: [NgStyle, NgIf, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent, DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
NgxSliderModule, ReactiveFormsModule, NgFor, NgSwitch, NgSwitchCase, FittingIconPipe, ReaderModeIconPipe, NgxSliderModule, ReactiveFormsModule, NgFor, NgSwitch, NgSwitchCase, FittingIconPipe, ReaderModeIconPipe,
FullscreenIconPipe, TranslocoDirective, NgbProgressbar, PercentPipe, NgClass, AsyncPipe] FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective]
}) })
export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ -1656,7 +1657,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* Bookmarks the current page for the chapter * Bookmarks the current page for the chapter
*/ */
bookmarkPage(event: MouseEvent | undefined = undefined) { bookmarkPage(event: Event | undefined = undefined) {
if (event) { if (event) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();

View File

@ -14,11 +14,24 @@
<div class="main-container container-fluid pt-2 mb-5"> <div class="main-container container-fluid pt-2 mb-5">
<div class="row mb-0 mb-xl-3 info-container"> <div class="row mb-0 mb-xl-3 info-container">
@if (HasCoverImage) {
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mt-2"> <div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mt-2">
<app-image [styles]="{'background': 'none', 'max-height': '400px', 'height': '200px', 'width': '200px', 'border-radius': '50%'}" <div class="person-badge">
<app-image
objectFit="cover"
height="200px"
width="200px"
[imageUrl]="imageService.getPersonImage(person.id)" [imageUrl]="imageService.getPersonImage(person.id)"
[errorImage]="imageService.noPersonImage"></app-image> [errorImage]="imageService.noPersonImage"></app-image>
</div> </div>
</div>
} @else {
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mt-2">
<div class="person-badge d-flex align-items-center justify-content-center">
<i class="fas fa-user" aria-hidden="true"></i>
</div>
</div>
}
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2"> <div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
<div class="row g-0 mt-2"> <div class="row g-0 mt-2">

View File

@ -1,4 +1,19 @@
.main-container { .main-container {
margin-top: 10px; margin-top: 10px;
padding: 0 0 0 10px; padding: 0 0 0 10px;
.person-badge {
background: var(--card-bg-color);
max-height: 200px;
height: 200px;
width: 200px;
border-radius: 50%;
overflow: hidden;
i {
max-height: 128px;
height: 128px;
font-size: 8rem;
}
}
} }

View File

@ -39,6 +39,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {ThemeService} from "../_services/theme.service"; import {ThemeService} from "../_services/theme.service";
import {DefaultModalOptions} from "../_models/default-modal-options"; import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr";
@Component({ @Component({
selector: 'app-person-detail', selector: 'app-person-detail',
@ -74,6 +75,7 @@ export class PersonDetailComponent {
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);
protected readonly accountService = inject(AccountService); protected readonly accountService = inject(AccountService);
private readonly themeService = inject(ThemeService); private readonly themeService = inject(ThemeService);
private readonly toastr = inject(ToastrService);
protected readonly TagBadgeCursor = TagBadgeCursor; protected readonly TagBadgeCursor = TagBadgeCursor;
@ -92,6 +94,10 @@ export class PersonDetailComponent {
private readonly personSubject = new BehaviorSubject<Person | null>(null); private readonly personSubject = new BehaviorSubject<Person | null>(null);
protected readonly person$ = this.personSubject.asObservable(); protected readonly person$ = this.personSubject.asObservable();
get HasCoverImage() {
return (this.person as Person).coverImage;
}
constructor() { constructor() {
this.route.paramMap.pipe( this.route.paramMap.pipe(
switchMap(params => { switchMap(params => {
@ -104,7 +110,14 @@ export class PersonDetailComponent {
this.personName = personName; this.personName = personName;
return this.personService.get(personName); return this.personService.get(personName);
}), }),
tap(person => { tap((person) => {
if (person == null) {
this.toastr.error(translate('toasts.unauthorized-1'));
this.router.navigateByUrl('/home');
return;
}
this.person = person; this.person = person;
this.personSubject.next(person); // emit the person data for subscribers this.personSubject.next(person); // emit the person data for subscribers
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);

View File

@ -124,8 +124,8 @@
<!-- Summary row--> <!-- Summary row-->
<div class="row g-0 mt-2"> <div class="row g-0 my-2">
<app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more> <app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div> </div>
@if (characters$ | async; as characters) { @if (characters$ | async; as characters) {

View File

@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {AgeRatingImageComponent} from "../../../_single-modules/age-rating-image/age-rating-image.component"; import {AgeRatingImageComponent} from "../../../_single-module/age-rating-image/age-rating-image.component";
import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe"; import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe"; import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
import {ReadTimePipe} from "../../../_pipes/read-time.pipe"; import {ReadTimePipe} from "../../../_pipes/read-time.pipe";
@ -17,7 +17,7 @@ import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {MangaFormat} from "../../../_models/manga-format"; import {MangaFormat} from "../../../_models/manga-format";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
import {PublisherFlipperComponent} from "../../../_single-modules/publisher-flipper/publisher-flipper.component"; import {PublisherFlipperComponent} from "../../../_single-module/publisher-flipper/publisher-flipper.component";
@Component({ @Component({
selector: 'app-metadata-detail-row', selector: 'app-metadata-detail-row',

View File

@ -109,7 +109,7 @@
</div> </div>
<div class="mt-2 mb-3"> <div class="mt-2 mb-3">
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more> <app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div> </div>
<div class="mt-2 upper-details"> <div class="mt-2 upper-details">

View File

@ -115,7 +115,7 @@ import {DownloadButtonComponent} from "../download-button/download-button.compon
import {hasAnyCast} from "../../../_models/common/i-has-cast"; import {hasAnyCast} from "../../../_models/common/i-has-cast";
import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component"; import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component";
import {CoverUpdateEvent} from "../../../_models/events/cover-update-event"; import {CoverUpdateEvent} from "../../../_models/events/cover-update-event";
import {RelatedSeriesPair, RelatedTabComponent} from "../../../_single-modules/related-tab/related-tab.component"; import {RelatedSeriesPair, RelatedTabComponent} from "../../../_single-module/related-tab/related-tab.component";
import {CollectionTagService} from "../../../_services/collection-tag.service"; import {CollectionTagService} from "../../../_services/collection-tag.service";
import {UserCollection} from "../../../_models/collection-tag"; import {UserCollection} from "../../../_models/collection-tag";
import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component"; import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component";

View File

@ -1,4 +1,5 @@
<img class="img-placeholder fade-in" <img class="img-placeholder fade-in"
[style]="{ 'object-fit': objectFit }"
#img #img
alt="" alt=""
aria-hidden="true" aria-hidden="true"

View File

@ -65,6 +65,10 @@ export class ImageComponent implements OnChanges {
* If the image load fails, instead of showing an error image, hide the image (visibility) * If the image load fails, instead of showing an error image, hide the image (visibility)
*/ */
@Input() hideOnError: boolean = false; @Input() hideOnError: boolean = false;
/**
* Sets the object-fit property of the image. Default is 'fill'.
*/
@Input() objectFit: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' = 'fill';
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>; @ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;

View File

@ -1,30 +1,26 @@
@if (person !== undefined) { @if (person !== undefined) {
<div class="tagbadge cursor clickable">
<div class="d-flex flex-column">
@if (HasCoverImage) {
<div class="mx-auto">
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}"> <a class="btn btn-icon p-0" routerLink="/person/{{person.name}}">
<app-image height="24px" width="24px" [styles]="{'background': 'none', 'max-height': '48px', 'height': '48px', 'width': '48px', 'border-radius': '50%'}" <div class="tagbadge cursor clickable">
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="image-container d-flex align-items-center justify-content-center">
@if (HasCoverImage) {
<app-image
objectFit="cover"
height="96px"
width="96px"
[imageUrl]="ImageUrl" [imageUrl]="ImageUrl"
[errorImage]="imageService.noPersonImage"> [errorImage]="imageService.noPersonImage">
</app-image> </app-image>
</a>
</div>
} @else { } @else {
<div style="background: none; max-height: 48px; height: 48px; width: 48px; border-radius: 50%" class="mx-auto">
<i class="fas fa-user" aria-hidden="true"></i> <i class="fas fa-user" aria-hidden="true"></i>
</div>
} }
</div>
<div class="flex-grow-1 text-center mt-2"> <div class="flex-grow-1 text-center mt-2">
@if (isStaff) {
<span class="mt-1 mb-0">{{person.name}}</span> <span class="mt-1 mb-0">{{person.name}}</span>
} @else {
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}">{{person.name}}</a>
}
</div> </div>
</div> </div>
</div> </div>
</a>
} }

View File

@ -1,18 +1,31 @@
@import '../../../theme/_variables.scss';
.tagbadge { .tagbadge {
background-color: var(--tagbadge-bg-color); background-color: var(--tagbadge-bg-color);
transition: all .3s ease-out; transition: all .3s ease-out;
margin: 3px 5px 3px 0px; margin: 3px 10px 3px 0px;
padding: 2px 10px;
border-radius: 6px; border-radius: 6px;
font-size: .8rem; font-size: .8rem;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
width: 120px; width: 96px;
word-break: break-word; word-break: break-word;
i { i {
max-height: 48px;
height: 48px;
width: 48px;
font-size: 2.96rem; font-size: 2.96rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
} }
.image-container {
background: var(--card-bg-color);
max-height: 96px;
height: 96px;
width: 96px;
border-radius: 50%;
overflow: hidden;
}
} }

View File

@ -1,3 +1,5 @@
@import "../../../theme/variables";
.blur-text { .blur-text {
color: transparent; color: transparent;
text-shadow: 0 0 5px var(--body-text-color); text-shadow: 0 0 5px var(--body-text-color);
@ -8,5 +10,10 @@
div { div {
word-break: break-word; word-break: break-word;
max-width: 75ch;
@media (max-width: $grid-breakpoints-sm) {
max-width: 50ch;
}
} }
} }

View File

@ -89,7 +89,7 @@
</div> </div>
<div class="mt-2 mb-3"> <div class="mt-2 mb-3">
<app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more> <app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div> </div>
<div class="mt-2"> <div class="mt-2">

View File

@ -59,7 +59,7 @@ import {
} from "../_single-module/edit-volume-modal/edit-volume-modal.component"; } from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {Genre} from "../_models/metadata/genre"; import {Genre} from "../_models/metadata/genre";
import {Tag} from "../_models/tag"; import {Tag} from "../_models/tag";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component";
import {ReadingList} from "../_models/reading-list"; import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service"; import {ReadingListService} from "../_services/reading-list.service";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";

View File

@ -12,6 +12,7 @@
border-radius: var(--side-nav-border-radius); border-radius: var(--side-nav-border-radius);
transition: width var(--side-nav-openclose-transition), background-color var(--side-nav-bg-color-transition), border-color var(--side-nav-border-transition); transition: width var(--side-nav-openclose-transition), background-color var(--side-nav-bg-color-transition), border-color var(--side-nav-border-transition);
border: var(--side-nav-border); border: var(--side-nav-border);
overflow: hidden;
&::-webkit-scrollbar { &::-webkit-scrollbar {
visibility: hidden; visibility: hidden;
@ -27,7 +28,7 @@
} }
//START closed state of the sidebar //START closed state of the sidebar
&.closed { &.closed {
width: 4.0625rem; width: 2.825rem;
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden; overflow-y: hidden;
background-color: var(--side-nav-closed-bg-color); background-color: var(--side-nav-closed-bg-color);
@ -49,11 +50,6 @@
opacity: 0; opacity: 0;
} }
.side-nav-text {
opacity: 0;
display: none;
}
.card-actions { .card-actions {
opacity: 0; opacity: 0;
display: none; display: none;
@ -64,7 +60,7 @@
//END closed state of the sidebar //END closed state of the sidebar
//START sidebar //START sidebar
.side-nav { .side-nav {
overflow-y: hidden; overflow: hidden;
height: 100%; height: 100%;
scrollbar-gutter: stable; scrollbar-gutter: stable;
scrollbar-width: thin; scrollbar-width: thin;
@ -73,7 +69,7 @@
position: relative; position: relative;
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: space-between; justify-content: start;
width: 100%; width: 100%;
height: auto; height: auto;
min-height: 2.6rem; min-height: 2.6rem;
@ -89,6 +85,7 @@
&:first-of-type { &:first-of-type {
text-align: center; text-align: center;
width: 2.5rem; width: 2.5rem;
min-width: 2.5rem;
margin-left: 0.3rem; margin-left: 0.3rem;
} }
@ -101,7 +98,7 @@
align-items: center; align-items: center;
height: 100%; height: 100%;
justify-content: inherit; justify-content: inherit;
width: 100%; padding: 0 0.625rem;
i { i {
font-size: var(--side-nav-icon-size); font-size: var(--side-nav-icon-size);
@ -291,10 +288,6 @@
.side-nav-item { .side-nav-item {
width: 100%; width: 100%;
padding: 0; padding: 0;
display: block;
line-height: 2.5rem;
text-align: center;
min-height: unset;
color: var(--side-nav-item-closed-color); color: var(--side-nav-item-closed-color);
&:hover { &:hover {
@ -317,11 +310,13 @@
margin: 0; margin: 0;
left: 0; left: 0;
top: 0; top: 0;
transition: width var(--side-nav-openclose-transition); transition: width var(--side-nav-openclose-transition), visibility var(--side-nav-openclose-transition);
z-index: 1050; z-index: 1050;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
border: var(--side-nav-mobile-border); border: var(--side-nav-mobile-border);
border-radius: 0rem; border-radius: 0rem;
visibility: visible;
&.preference { &.preference {
background-color: unset; background-color: unset;
@ -349,8 +344,10 @@
&.closed { &.closed {
width: 0; width: 0;
background-color: var(--side-nav-mobile-bg-color);
overflow: hidden; overflow: hidden;
box-shadow: none; box-shadow: none;
visibility: hidden;
} }
.side-nav { .side-nav {
@ -383,9 +380,13 @@
left: 0; left: 0;
top: 0; top: 0;
z-index: 1041; z-index: 1041;
visibility: visible;
opacity: 1;
transition: visibility var(--side-nav-openclose-transition), opacity var(--side-nav-openclose-transition);
&.closed { &.closed {
display: none; visibility: hidden;
opacity: 0;
} }
} }
} }