Compare commits
15 commits
develop
...
feature/pl
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8464835d27 | ||
![]() |
11e096b69c | ||
![]() |
ca50549c1b | ||
![]() |
3b5189c83f | ||
![]() |
e41eddb7a7 | ||
![]() |
fffc21a53d | ||
![]() |
0a7bc4b3f6 | ||
![]() |
2a43deea24 | ||
![]() |
cfb7ef54f6 | ||
![]() |
4f13ae0f0b | ||
![]() |
634b165318 | ||
![]() |
d522bccf86 | ||
![]() |
c094c18b1c | ||
![]() |
e7fb2017ea | ||
![]() |
2091b35cef |
49 changed files with 1130 additions and 347 deletions
20
.github/workflows/develop-workflow.yml
vendored
20
.github/workflows/develop-workflow.yml
vendored
|
@ -128,7 +128,7 @@ jobs:
|
|||
- name: Compile dotnet app
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Install Swashbuckle CLI
|
||||
run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
|
||||
|
@ -137,6 +137,7 @@ jobs:
|
|||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.repository_owner == 'Kareadita' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
@ -155,20 +156,33 @@ jobs:
|
|||
id: buildx
|
||||
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
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
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
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Notify Discord
|
||||
uses: rjstone/discord-webhook-notify@v1
|
||||
if: ${{ github.repository_owner == 'Kareadita' }}
|
||||
with:
|
||||
severity: info
|
||||
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
||||
|
|
33
.github/workflows/release-workflow.yml
vendored
33
.github/workflows/release-workflow.yml
vendored
|
@ -114,6 +114,7 @@ jobs:
|
|||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.repository_owner == 'Kareadita' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
@ -132,23 +133,47 @@ jobs:
|
|||
id: buildx
|
||||
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
|
||||
id: docker_build_stable
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
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
|
||||
id: docker_build_nightly
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
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
|
||||
run: echo ${{ steps.docker_build_stable.outputs.digest }}
|
||||
|
|
|
@ -123,14 +123,14 @@ public class QueryableExtensionsTests
|
|||
|
||||
[Theory]
|
||||
[InlineData(true, 2)]
|
||||
[InlineData(false, 1)]
|
||||
public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
|
||||
[InlineData(false, 2)]
|
||||
public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount)
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<Person>
|
||||
{
|
||||
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)
|
||||
};
|
||||
|
||||
|
@ -144,7 +144,7 @@ public class QueryableExtensionsTests
|
|||
var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedCount, filtered.Count());
|
||||
Assert.Equal(expectedPeopleCount, filtered.Count());
|
||||
}
|
||||
|
||||
private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings)
|
||||
|
|
|
@ -509,6 +509,21 @@ public class AccountController : BaseApiController
|
|||
_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
|
||||
var existingRoles = await _userManager.GetRolesAsync(user);
|
||||
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"));
|
||||
|
||||
dto.Email = dto.Email.Trim();
|
||||
if (string.IsNullOrEmpty(dto.Email))
|
||||
return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
|
||||
if (string.IsNullOrEmpty(dto.Email)) return BadRequest(await _localizationService.Translate(userId, "invalid-payload"));
|
||||
|
||||
_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);
|
||||
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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -18,4 +18,8 @@ public record UpdateUserDto
|
|||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
|
||||
/// <summary>
|
||||
/// Email of the user
|
||||
/// </summary>
|
||||
public string? Email { get; set; } = default!;
|
||||
}
|
||||
|
|
|
@ -101,12 +101,15 @@ public static class RestrictByAgeExtensions
|
|||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadataPeople.All(sm =>
|
||||
sm.SeriesMetadata.AgeRating <= restriction.AgeRating));
|
||||
return queryable.Where(c =>
|
||||
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 =>
|
||||
sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating > AgeRating.Unknown));
|
||||
return queryable.Where(c =>
|
||||
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)
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"age-restriction-update": "There was an error updating the age restriction",
|
||||
"no-user": "User does not exist",
|
||||
"username-taken": "Username already taken",
|
||||
"email-taken": "Email already in use",
|
||||
"user-already-confirmed": "User is already confirmed",
|
||||
"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",
|
||||
|
|
|
@ -95,12 +95,12 @@ public class AccountService : IAccountService
|
|||
public async Task<IEnumerable<ApiException>> ValidateEmail(string 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")
|
||||
};
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -14,6 +14,7 @@ using API.DTOs.Stats.V3;
|
|||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Kavita.Common.Helpers;
|
||||
|
@ -231,11 +232,12 @@ public class StatsService : IStatsService
|
|||
{
|
||||
// If first time flow, just return 0
|
||||
if (!await _context.Chapter.AnyAsync()) return 0;
|
||||
|
||||
return await _context.Series
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.MaxAsync(s => s.Volumes!
|
||||
.Where(v => v.MinNumber == 0)
|
||||
.Where(v => v.MinNumber == Parser.LooseLeafVolumeNumber)
|
||||
.SelectMany(v => v.Chapters!)
|
||||
.Count());
|
||||
}
|
||||
|
@ -262,13 +264,13 @@ public class StatsService : IStatsService
|
|||
dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary();
|
||||
dto.MaxVolumesInASeries = await MaxVolumesInASeries();
|
||||
dto.MaxChaptersInASeries = await MaxChaptersInASeries();
|
||||
dto.TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles();
|
||||
dto.TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync();
|
||||
dto.TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync();
|
||||
dto.TotalSeries = await _unitOfWork.SeriesRepository.GetCountAsync();
|
||||
dto.TotalLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count();
|
||||
dto.NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count();
|
||||
dto.NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count();
|
||||
dto.TotalFiles = await _context.MangaFile.CountAsync();
|
||||
dto.TotalGenres = await _context.Genre.CountAsync();
|
||||
dto.TotalPeople = await _context.Person.CountAsync();
|
||||
dto.TotalSeries = await _context.Series.CountAsync();
|
||||
dto.TotalLibraries = await _context.Library.CountAsync();
|
||||
dto.NumberOfCollections = await _context.AppUserCollection.CountAsync();
|
||||
dto.NumberOfReadingLists = await _context.ReadingList.CountAsync();
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -314,6 +316,7 @@ public class StatsService : IStatsService
|
|||
libDto.UsingFolderWatching = library.FolderWatching;
|
||||
libDto.CreateCollectionsFromMetadata = library.ManageCollections;
|
||||
libDto.CreateReadingListsFromMetadata = library.ManageReadingLists;
|
||||
libDto.LibraryType = library.Type;
|
||||
|
||||
dto.Libraries.Add(libDto);
|
||||
}
|
||||
|
|
3
UI/Web/.gitignore
vendored
3
UI/Web/.gitignore
vendored
|
@ -2,3 +2,6 @@ node_modules/
|
|||
test-results/
|
||||
playwright-report/
|
||||
i18n-cache-busting.json
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
|
8
UI/Web/e2e-tests/environment.ts
Normal file
8
UI/Web/e2e-tests/environment.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* This is public information - create a environment.local.ts file and use admin account there
|
||||
*/
|
||||
export const environment = {
|
||||
baseUrl: 'https://demo.kavitareader.com/',
|
||||
username: 'demouser',
|
||||
password: 'Demouser64',
|
||||
};
|
18
UI/Web/e2e-tests/example.spec.ts
Normal file
18
UI/Web/e2e-tests/example.spec.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
19
UI/Web/e2e-tests/pages/login-page.ts
Normal file
19
UI/Web/e2e-tests/pages/login-page.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Page } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async navigate() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.page.fill('input[formControlName="username"]', username);
|
||||
await this.page.fill('input[formControlName="password"]', password);
|
||||
await this.page.click('button[type="submit"]');
|
||||
}
|
||||
}
|
26
UI/Web/e2e-tests/tests/Login/login.spec.ts
Normal file
26
UI/Web/e2e-tests/tests/Login/login.spec.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from 'e2e-tests/pages/login-page';
|
||||
import {environment} from "../../environment";
|
||||
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto(environment.baseUrl);
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Kavita/);
|
||||
});
|
||||
|
||||
test('login functionality', async ({ page }) => {
|
||||
// Navigate to the login page
|
||||
await page.goto(environment.baseUrl);
|
||||
|
||||
// Verify the page title
|
||||
await expect(page).toHaveTitle(/Kavita/);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
//await loginPage.navigate();
|
||||
await loginPage.login(environment.username, environment.password);
|
||||
|
||||
// Verify successful login by checking for Home on side nav
|
||||
await expect(page.locator('#null')).toBeVisible();
|
||||
});
|
75
UI/Web/package-lock.json
generated
75
UI/Web/package-lock.json
generated
|
@ -60,6 +60,7 @@
|
|||
"@angular/build": "^18.2.10",
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.9",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/luxon": "^3.4.0",
|
||||
|
@ -464,7 +465,6 @@
|
|||
"version": "18.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz",
|
||||
"integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.25.2",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
|
@ -492,7 +492,6 @@
|
|||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
|
@ -507,7 +506,6 @@
|
|||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
|
@ -2461,6 +2459,21 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz",
|
||||
"integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.49.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.25",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
|
||||
|
@ -4010,8 +4023,7 @@
|
|||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
|
@ -4518,7 +4530,6 @@
|
|||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
|
@ -4528,7 +4539,6 @@
|
|||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
|
@ -7322,6 +7332,50 @@
|
|||
"nice-napi": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz",
|
||||
"integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz",
|
||||
"integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.41",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
|
||||
|
@ -7470,8 +7524,7 @@
|
|||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"node_modules/replace-in-file": {
|
||||
"version": "7.1.0",
|
||||
|
@ -7742,7 +7795,7 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.77.6",
|
||||
|
@ -7776,7 +7829,6 @@
|
|||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
@ -8331,7 +8383,6 @@
|
|||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale",
|
||||
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
"e2e": "npx playwright test --ui"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
@ -68,6 +68,7 @@
|
|||
"@angular/build": "^18.2.10",
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.9",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/luxon": "^3.4.0",
|
||||
|
|
79
UI/Web/playwright.config.ts
Normal file
79
UI/Web/playwright.config.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e-tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
27
UI/Web/src/app/_directives/dbl-click.directive.ts
Normal file
27
UI/Web/src/app/_directives/dbl-click.directive.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -26,7 +26,7 @@ export class PersonService {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@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)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
} @else {
|
||||
<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)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
{{review.isExternal ? t('external-review') : t('local-review')}}
|
||||
</h6>-->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,76 +1,90 @@
|
|||
<ng-container *transloco="let t; read: 'edit-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<h4>{{t('account-detail-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12 pe-4">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{t('username')}}</label>
|
||||
<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">
|
||||
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
{{t('required')}}
|
||||
</div>
|
||||
<div *ngIf="userForm.get('username')?.errors?.pattern">
|
||||
{{t('username-pattern', {characters: allowedCharacters})}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<input class="form-control" inputmode="email" type="email" id="email"
|
||||
[class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched"
|
||||
formControlName="email" aria-describedby="email-validations">
|
||||
<div id="email-validations" class="invalid-feedback"
|
||||
*ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
{{t('required')}}
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
{{t('not-valid-email')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<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>
|
||||
<span>{{isSaving ? t('saving') : t('update')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body scrollable-modal">
|
||||
<form [formGroup]="userForm">
|
||||
<h4>{{t('account-detail-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12 pe-4">
|
||||
@if(userForm.get('username'); as formControl) {
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{t('username')}}</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched" aria-describedby="username-validations">
|
||||
@if(formControl.dirty || !formControl.untouched) {
|
||||
<div id="username-validations" class="invalid-feedback">
|
||||
@if (formControl.errors; as errors) {
|
||||
<div>
|
||||
@if (errors.required) {
|
||||
{{t('required')}}
|
||||
} @else if (errors.pattern) {
|
||||
{{t('username-pattern', {characters: allowedCharacters})}}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if(userForm.get('email'); as formControl) {
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<input id="email" class="form-control" formControlName="email" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched" aria-describedby="email-validations">
|
||||
@if(formControl.dirty || !formControl.untouched) {
|
||||
<div id="email-validations" class="invalid-feedback">
|
||||
@if (formControl.errors; as errors) {
|
||||
<div>
|
||||
@if (errors.required) {
|
||||
{{t('required')}}
|
||||
} @else if (errors.email) {
|
||||
{{t('not-valid-email')}}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
@if (isSaving) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
<span>{{isSaving ? t('saving') : t('update')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -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('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||
|
||||
this.userForm.get('email')?.disable();
|
||||
this.selectedRestriction = this.member.ageRestriction;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
|
|
@ -290,7 +290,7 @@
|
|||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@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>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
.companion-bar {
|
||||
transition: all var(--side-nav-companion-bar-transistion);
|
||||
margin-left: 60px;
|
||||
margin-left: 45px;
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
height: calc(var(--vh)* 100 - var(--nav-mobile-offset));
|
||||
|
|
|
@ -85,7 +85,7 @@
|
|||
</div>
|
||||
|
||||
<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 class="mt-2">
|
||||
|
|
|
@ -48,7 +48,7 @@ import {FilterUtilitiesService} from "../shared/_services/filter-utilities.servi
|
|||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ReadingList} from "../_models/reading-list";
|
||||
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 {
|
||||
MetadataDetailRowComponent
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
@if (summary.length > 0) {
|
||||
<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>
|
||||
|
||||
@if (collectionTag.source !== ScrobbleProvider.Kavita) {
|
||||
|
|
|
@ -3,47 +3,58 @@
|
|||
@if(debugMode) {
|
||||
<div class="fixed-top overlay">
|
||||
@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>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="fixed-top overlay" *ngIf="menuOpen" [@slideFromTop]="menuOpen">
|
||||
<div style="display: flex; margin-top: 5px;">
|
||||
<button class="btn btn-icon" style="height: 100%" [title]="t('back')" (click)="closeReader()">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('back')}}</span>
|
||||
</button>
|
||||
@if (menuOpen) {
|
||||
<div class="fixed-top overlay" [@slideFromTop]="menuOpen">
|
||||
<div style="display: flex; margin-top: 5px;">
|
||||
<button class="btn btn-icon" style="height: 100%" [title]="t('back')" (click)="closeReader()">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('back')}}</span>
|
||||
</button>
|
||||
|
||||
<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 class="subtitle">
|
||||
{{subtitle}} <span *ngIf="totalSeriesPages > 0">{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</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">
|
||||
{{subtitle}}
|
||||
@if (totalSeriesPages > 0) {
|
||||
<span>{{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: auto; padding-right: 3%;">
|
||||
<button class="btn btn-icon" title="Shortcuts" (click)="openShortcutModal()">
|
||||
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('shortcuts-menu-alt')}}</span>
|
||||
</button>
|
||||
@if (!bookmarkMode && hasBookmarkRights) {
|
||||
<button class="btn btn-icon" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
|
||||
title="{{t(CurrentPageBookmarked ? 'unbookmark-page-tooltip' : 'bookmark-page-tooltip')}}" (click)="bookmarkPage()">
|
||||
<i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t(CurrentPageBookmarked ? 'unbookmark-page-tooltip' : 'bookmark-page-tooltip')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: auto; padding-right: 3%;">
|
||||
<button class="btn btn-icon" title="Shortcuts" (click)="openShortcutModal()">
|
||||
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('shortcuts-menu-alt')}}</span>
|
||||
</button>
|
||||
@if (!bookmarkMode && hasBookmarkRights) {
|
||||
<button class="btn btn-icon" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
|
||||
title="{{t(CurrentPageBookmarked ? 'unbookmark-page-tooltip' : 'bookmark-page-tooltip')}}" (click)="bookmarkPage()">
|
||||
<i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t(CurrentPageBookmarked ? 'unbookmark-page-tooltip' : 'bookmark-page-tooltip')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading>
|
||||
<div class="reading-area"
|
||||
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
|
||||
[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)">
|
||||
<app-canvas-renderer
|
||||
[readerSettings$]="readerSettings$"
|
||||
|
@ -57,24 +68,28 @@
|
|||
<div class="pagination-area">
|
||||
<div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)"
|
||||
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), 'max-height': MaxHeight}">
|
||||
<div *ngIf="showClickOverlay">
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
|
||||
[title]="t('prev-page-tooltip')" aria-hidden="true"></i>
|
||||
</div>
|
||||
@if (showClickOverlay) {
|
||||
<div>
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
|
||||
[title]="t('prev-page-tooltip')" aria-hidden="true"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)"
|
||||
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'),
|
||||
'left': 'inherit',
|
||||
'right': RightPaginationOffset + 'px',
|
||||
'max-height': MaxHeight}">
|
||||
<div *ngIf="showClickOverlay">
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}"
|
||||
[title]="t('next-page-tooltip')" aria-hidden="true"></i>
|
||||
</div>
|
||||
@if (showClickOverlay) {
|
||||
<div>
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'right' : 'down'}}"
|
||||
[title]="t('next-page-tooltip')" aria-hidden="true"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div (dblclick)="bookmarkPage($event)">
|
||||
<div appDblClick (doubleClick)="bookmarkPage($event)">
|
||||
<app-single-renderer [image$]="currentImage$"
|
||||
[readerSettings$]="readerSettings$"
|
||||
[bookmark$]="showBookmarkEffect$"
|
||||
|
@ -106,196 +121,200 @@
|
|||
[getPage]="getPageFn">
|
||||
</app-double-no-cover-renderer>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #webtoon>
|
||||
<div class="webtoon-images" *ngIf="!isLoading && !inSetup">
|
||||
<app-infinite-scroller [pageNum]="pageNum"
|
||||
[bufferPages]="5"
|
||||
[goToPage]="goToPageEvent"
|
||||
(pageNumberChange)="handleWebtoonPageChange($event)"
|
||||
[totalPages]="maxPages"
|
||||
[urlProvider]="getPageUrl"
|
||||
(loadNextChapter)="loadNextChapter()"
|
||||
(loadPrevChapter)="loadPrevChapter()"
|
||||
[bookmarkPage]="showBookmarkEffectEvent"
|
||||
[fullscreenToggled]="fullscreenEvent"
|
||||
[readerSettings$]="readerSettings$">
|
||||
</app-infinite-scroller>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
} @else {
|
||||
@if (!isLoading && !inSetup) {
|
||||
<div class="webtoon-images">
|
||||
<app-infinite-scroller [pageNum]="pageNum"
|
||||
[bufferPages]="5"
|
||||
[goToPage]="goToPageEvent"
|
||||
(pageNumberChange)="handleWebtoonPageChange($event)"
|
||||
[totalPages]="maxPages"
|
||||
[urlProvider]="getPageUrl"
|
||||
(loadNextChapter)="loadNextChapter()"
|
||||
(loadPrevChapter)="loadPrevChapter()"
|
||||
[bookmarkPage]="showBookmarkEffectEvent"
|
||||
[fullscreenToggled]="fullscreenEvent"
|
||||
[readerSettings$]="readerSettings$">
|
||||
</app-infinite-scroller>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom overlay" *ngIf="menuOpen" [@slideFromBottom]="menuOpen">
|
||||
|
||||
<div class="mb-3" *ngIf="pageOptions !== undefined && pageOptions.ceil !== undefined">
|
||||
<span class="visually-hidden" id="slider-info"></span>
|
||||
<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-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">
|
||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||
</div>
|
||||
<ng-template #noSlider>
|
||||
<div class="col custom-slider">
|
||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||
@if (menuOpen) {
|
||||
<div class="fixed-bottom overlay" [@slideFromBottom]="menuOpen">
|
||||
@if (pageOptions !== undefined && pageOptions.ceil !== undefined) {
|
||||
<div class="mb-3">
|
||||
<span class="visually-hidden" id="slider-info"></span>
|
||||
<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-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>
|
||||
@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>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="col custom-slider">
|
||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||
</div>
|
||||
}
|
||||
<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>
|
||||
</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-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" [title]="t('next-chapter-tooltip')"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
}
|
||||
<div class="row pt-4 ms-2 me-2 mb-2">
|
||||
<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')">
|
||||
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
|
||||
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" [title]="t('reading-mode-tooltip')" (click)="toggleReaderMode();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.readerMode | readerModeIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('reading-mode-tooltip')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" title="{{this.isFullscreen ? t('collapse') : t('fullscreen')}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.isFullscreen | fullscreenIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{this.isFullscreen ? t('collapse') : t('fullscreen')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" [title]="t('settings-tooltip')" (click)="settingsOpen = !settingsOpen;resetMenuCloseTimer();">
|
||||
<i class="fa fa-sliders-h" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('settings-tooltip')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pt-4 ms-2 me-2 mb-2">
|
||||
<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')">
|
||||
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
|
||||
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" [title]="t('reading-mode-tooltip')" (click)="toggleReaderMode();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.readerMode | readerModeIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('reading-mode-tooltip')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" title="{{this.isFullscreen ? t('collapse') : t('fullscreen')}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.isFullscreen | fullscreenIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{this.isFullscreen ? t('collapse') : t('fullscreen')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" [title]="t('settings-tooltip')" (click)="settingsOpen = !settingsOpen;resetMenuCloseTimer();">
|
||||
<i class="fa fa-sliders-h" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('settings-tooltip')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-menu" *ngIf="settingsOpen && generalSettingsForm">
|
||||
<form [formGroup]="generalSettingsForm">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="page-splitting" class="form-label">{{t('image-splitting-label')}}</label>
|
||||
<div class="split fa fa-image">
|
||||
<div class="{{SplitIconClass}}"></div>
|
||||
@if (settingsOpen && generalSettingsForm) {
|
||||
<div class="bottom-menu">
|
||||
<form [formGroup]="generalSettingsForm">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="page-splitting" class="form-label">{{t('image-splitting-label')}}</label>
|
||||
<div class="split fa fa-image">
|
||||
<div class="{{SplitIconClass}}"></div>
|
||||
</div>
|
||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||
<option *ngFor="let opt of pageSplitOptionsTranslated" [value]="opt.value">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="page-fitting" class="form-label">{{t('image-scaling-label')}}</label> <i class="{{FittingOption | fittingIcon}}" aria-hidden="true"></i>
|
||||
<select class="form-control" id="page-fitting" formControlName="fittingOption">
|
||||
<option value="full-height">{{t('height')}}</option>
|
||||
<option value="full-width">{{t('width')}}</option>
|
||||
<option value="original">{{t('original')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||
<option *ngFor="let opt of pageSplitOptionsTranslated" [value]="opt.value">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="page-fitting" class="form-label">{{t('image-scaling-label')}}</label> <i class="{{FittingOption | fittingIcon}}" aria-hidden="true"></i>
|
||||
<select class="form-control" id="page-fitting" formControlName="fittingOption">
|
||||
<option value="full-height">{{t('height')}}</option>
|
||||
<option value="full-width">{{t('width')}}</option>
|
||||
<option value="original">{{t('original')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="layout-mode" class="form-label">Layout Mode</label>
|
||||
<ng-container [ngSwitch]="layoutMode">
|
||||
<ng-container *ngSwitchCase="LayoutMode.Single">
|
||||
<div class="split-double">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="layout-mode" class="form-label">Layout Mode</label>
|
||||
<ng-container [ngSwitch]="layoutMode">
|
||||
<ng-container *ngSwitchCase="LayoutMode.Single">
|
||||
<div class="split-double">
|
||||
<span class="fa-stack fa-1x">
|
||||
<i class="fa-regular fa-square-full fa-stack-2x"></i>
|
||||
<i class="fa fa-image fa-stack-1x"></i>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LayoutMode.Double">
|
||||
<div class="split-double">
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LayoutMode.Double">
|
||||
<div class="split-double">
|
||||
<span class="fa-stack fa-1x">
|
||||
<i class="fa-regular fa-square-full fa-stack-2x"></i>
|
||||
<i class="fab fa-1 fa-stack-1x"></i>
|
||||
</span>
|
||||
<span class="fa-stack fa right">
|
||||
<span class="fa-stack fa right">
|
||||
<i class="fa-regular fa-square-full fa-stack-2x"></i>
|
||||
<i class="fab fa-2 fa-stack-1x"></i>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LayoutMode.DoubleReversed">
|
||||
<div class="split-double">
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LayoutMode.DoubleReversed">
|
||||
<div class="split-double">
|
||||
<span class="fa-stack fa-1x">
|
||||
<i class="fa-regular fa-square-full fa-stack-2x"></i>
|
||||
<i class="fab fa-2 fa-stack-1x"></i>
|
||||
</span>
|
||||
<span class="fa-stack fa right">
|
||||
<span class="fa-stack fa right">
|
||||
<i class="fa-regular fa-square-full fa-stack-2x"></i>
|
||||
<i class="fab fa-1 fa-stack-1x"></i>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<select class="form-control" id="layout-mode" formControlName="layoutMode">
|
||||
<option [value]="opt.value" *ngFor="let opt of layoutModesTranslated">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" >
|
||||
<label class="form-check-label" for="auto-close">{{t('auto-close-menu-label')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<select class="form-control" id="layout-mode" formControlName="layoutMode">
|
||||
<option [value]="opt.value" *ngFor="let opt of layoutModesTranslated">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" >
|
||||
<label class="form-check-label" for="auto-close">{{t('auto-close-menu-label')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="swipe-to-paginate" formControlName="swipeToPaginate" class="form-check-input" >
|
||||
<label class="form-check-label" for="swipe-to-paginate">{{t('swipe-enabled-label')}}</label>
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="swipe-to-paginate" formControlName="swipeToPaginate" class="form-check-input" >
|
||||
<label class="form-check-label" for="swipe-to-paginate">{{t('swipe-enabled-label')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="emulate-book" formControlName="emulateBook" class="form-check-input">
|
||||
<label class="form-check-label" for="emulate-book">{{t('emulate-comic-book-label')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="emulate-book" formControlName="emulateBook" class="form-check-input">
|
||||
<label class="form-check-label" for="emulate-book">{{t('emulate-comic-book-label')}}</label>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="darkness" class="form-label range-label">{{t('brightness-label')}}</label>
|
||||
<span class="ms-1 range-text">{{generalSettingsForm.get('darkness')?.value + '%'}}</span>
|
||||
<input type="range" class="form-range" id="darkness"
|
||||
min="10" max="100" step="1" formControlName="darkness">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="width-override-slider" class="form-label">{{t('width-override-label')}}:
|
||||
@if (widthOverrideLabel$ | async; as widthOverrideLabel) {
|
||||
{{ widthOverrideLabel ? widthOverrideLabel : t('off') }}
|
||||
}
|
||||
@else {
|
||||
{{t('off')}}
|
||||
}
|
||||
</label>
|
||||
<input id="width-override-slider" type="range" min="0" max="100" class="form-range" formControlName="widthSlider">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-primary" (click)="savePref()">{{t('save-globally')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="darkness" class="form-label range-label">{{t('brightness-label')}}</label>
|
||||
<span class="ms-1 range-text">{{generalSettingsForm.get('darkness')?.value + '%'}}</span>
|
||||
<input type="range" class="form-range" id="darkness"
|
||||
min="10" max="100" step="1" formControlName="darkness">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<label for="width-override-slider" class="form-label">{{t('width-override-label')}}:
|
||||
@if (widthOverrideLabel$ | async; as widthOverrideLabel) {
|
||||
{{ widthOverrideLabel ? widthOverrideLabel : t('off') }}
|
||||
}
|
||||
@else {
|
||||
{{t('off')}}
|
||||
}
|
||||
</label>
|
||||
<input id="width-override-slider" type="range" min="0" max="100" class="form-range" formControlName="widthSlider">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-primary" (click)="savePref()">{{t('save-globally')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
OnInit,
|
||||
ViewChild
|
||||
} 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 {
|
||||
BehaviorSubject,
|
||||
|
@ -70,6 +70,7 @@ import {SwipeDirective} from '../../../ng-swipe/ng-swipe.directive';
|
|||
import {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {DblClickDirective} from "../../../_directives/dbl-click.directive";
|
||||
|
||||
|
||||
const PREFETCH_PAGES = 10;
|
||||
|
@ -123,10 +124,10 @@ enum KeyDirection {
|
|||
])
|
||||
],
|
||||
standalone: true,
|
||||
imports: [NgStyle, NgIf, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
|
||||
imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
|
||||
DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
|
||||
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 {
|
||||
|
||||
|
@ -1656,7 +1657,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
/**
|
||||
* Bookmarks the current page for the chapter
|
||||
*/
|
||||
bookmarkPage(event: MouseEvent | undefined = undefined) {
|
||||
bookmarkPage(event: Event | undefined = undefined) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
|
|
@ -39,6 +39,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
|
|||
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
|
||||
import {ThemeService} from "../_services/theme.service";
|
||||
import {DefaultModalOptions} from "../_models/default-modal-options";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-person-detail',
|
||||
|
@ -74,6 +75,7 @@ export class PersonDetailComponent {
|
|||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
|
@ -104,7 +106,14 @@ export class PersonDetailComponent {
|
|||
this.personName = 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.personSubject.next(person); // emit the person data for subscribers
|
||||
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
|
||||
|
|
|
@ -124,8 +124,8 @@
|
|||
|
||||
|
||||
<!-- Summary row-->
|
||||
<div class="row g-0 mt-2">
|
||||
<app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
|
||||
<div class="row g-0 my-2">
|
||||
<app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
|
||||
</div>
|
||||
|
||||
@if (characters$ | async; as characters) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.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 {MangaFormat} from "../../../_models/manga-format";
|
||||
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({
|
||||
selector: 'app-metadata-detail-row',
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
</div>
|
||||
|
||||
<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 class="mt-2 upper-details">
|
||||
|
|
|
@ -115,7 +115,7 @@ import {DownloadButtonComponent} from "../download-button/download-button.compon
|
|||
import {hasAnyCast} from "../../../_models/common/i-has-cast";
|
||||
import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component";
|
||||
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 {UserCollection} from "../../../_models/collection-tag";
|
||||
import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component";
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@import "../../../theme/variables";
|
||||
|
||||
.blur-text {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 5px var(--body-text-color);
|
||||
|
@ -8,5 +10,10 @@
|
|||
|
||||
div {
|
||||
word-break: break-word;
|
||||
max-width: 75ch;
|
||||
|
||||
@media (max-width: $grid-breakpoints-sm) {
|
||||
max-width: 50ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
</div>
|
||||
|
||||
<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 class="mt-2">
|
||||
|
|
|
@ -59,7 +59,7 @@ import {
|
|||
} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
|
||||
import {Genre} from "../_models/metadata/genre";
|
||||
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 {ReadingListService} from "../_services/reading-list.service";
|
||||
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
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);
|
||||
border: var(--side-nav-border);
|
||||
overflow: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
visibility: hidden;
|
||||
|
@ -27,7 +28,7 @@
|
|||
}
|
||||
//START closed state of the sidebar
|
||||
&.closed {
|
||||
width: 4.0625rem;
|
||||
width: 2.825rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--side-nav-closed-bg-color);
|
||||
|
@ -49,11 +50,6 @@
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
.side-nav-text {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
|
@ -64,7 +60,7 @@
|
|||
//END closed state of the sidebar
|
||||
//START sidebar
|
||||
.side-nav {
|
||||
overflow-y: hidden;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
|
@ -73,7 +69,7 @@
|
|||
position: relative;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: start;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 2.6rem;
|
||||
|
@ -89,6 +85,7 @@
|
|||
&:first-of-type {
|
||||
text-align: center;
|
||||
width: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
|
@ -101,7 +98,7 @@
|
|||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: inherit;
|
||||
width: 100%;
|
||||
padding: 0 0.625rem;
|
||||
|
||||
i {
|
||||
font-size: var(--side-nav-icon-size);
|
||||
|
@ -291,10 +288,6 @@
|
|||
.side-nav-item {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
display: block;
|
||||
line-height: 2.5rem;
|
||||
text-align: center;
|
||||
min-height: unset;
|
||||
color: var(--side-nav-item-closed-color);
|
||||
|
||||
&:hover {
|
||||
|
@ -317,11 +310,13 @@
|
|||
margin: 0;
|
||||
left: 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;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border: var(--side-nav-mobile-border);
|
||||
border-radius: 0rem;
|
||||
visibility: visible;
|
||||
|
||||
&.preference {
|
||||
background-color: unset;
|
||||
|
@ -349,8 +344,10 @@
|
|||
|
||||
&.closed {
|
||||
width: 0;
|
||||
background-color: var(--side-nav-mobile-bg-color);
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
|
@ -383,9 +380,13 @@
|
|||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1041;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: visibility var(--side-nav-openclose-transition), opacity var(--side-nav-openclose-transition);
|
||||
|
||||
&.closed {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
437
UI/Web/tests-examples/demo-todo-app.spec.ts
Normal file
437
UI/Web/tests-examples/demo-todo-app.spec.ts
Normal file
|
@ -0,0 +1,437 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
});
|
||||
|
||||
const TODO_ITEMS = [
|
||||
'buy some cheese',
|
||||
'feed the cat',
|
||||
'book a doctors appointment'
|
||||
] as const;
|
||||
|
||||
test.describe('New Todo', () => {
|
||||
test('should allow me to add todo items', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
// Create 1st todo.
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press('Enter');
|
||||
|
||||
// Make sure the list only has one todo item.
|
||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
||||
TODO_ITEMS[0]
|
||||
]);
|
||||
|
||||
// Create 2nd todo.
|
||||
await newTodo.fill(TODO_ITEMS[1]);
|
||||
await newTodo.press('Enter');
|
||||
|
||||
// Make sure the list now has two todo items.
|
||||
await expect(page.getByTestId('todo-title')).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
TODO_ITEMS[1]
|
||||
]);
|
||||
|
||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
});
|
||||
|
||||
test('should clear text input field when an item is added', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
// Create one todo item.
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press('Enter');
|
||||
|
||||
// Check that input is empty.
|
||||
await expect(newTodo).toBeEmpty();
|
||||
await checkNumberOfTodosInLocalStorage(page, 1);
|
||||
});
|
||||
|
||||
test('should append new items to the bottom of the list', async ({ page }) => {
|
||||
// Create 3 items.
|
||||
await createDefaultTodos(page);
|
||||
|
||||
// create a todo count locator
|
||||
const todoCount = page.getByTestId('todo-count')
|
||||
|
||||
// Check test using different methods.
|
||||
await expect(page.getByText('3 items left')).toBeVisible();
|
||||
await expect(todoCount).toHaveText('3 items left');
|
||||
await expect(todoCount).toContainText('3');
|
||||
await expect(todoCount).toHaveText(/3/);
|
||||
|
||||
// Check all items in one call.
|
||||
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mark all as completed', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test('should allow me to mark all items as completed', async ({ page }) => {
|
||||
// Complete all todos.
|
||||
await page.getByLabel('Mark all as complete').check();
|
||||
|
||||
// Ensure all todos have 'completed' class.
|
||||
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test('should allow me to clear the complete state of all items', async ({ page }) => {
|
||||
const toggleAll = page.getByLabel('Mark all as complete');
|
||||
// Check and then immediately uncheck.
|
||||
await toggleAll.check();
|
||||
await toggleAll.uncheck();
|
||||
|
||||
// Should be no completed classes.
|
||||
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
|
||||
});
|
||||
|
||||
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
||||
const toggleAll = page.getByLabel('Mark all as complete');
|
||||
await toggleAll.check();
|
||||
await expect(toggleAll).toBeChecked();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// Uncheck first todo.
|
||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||
await firstTodo.getByRole('checkbox').uncheck();
|
||||
|
||||
// Reuse toggleAll locator and make sure its not checked.
|
||||
await expect(toggleAll).not.toBeChecked();
|
||||
|
||||
await firstTodo.getByRole('checkbox').check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// Assert the toggle all is checked again.
|
||||
await expect(toggleAll).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Item', () => {
|
||||
|
||||
test('should allow me to mark items as complete', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
// Create two items.
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press('Enter');
|
||||
}
|
||||
|
||||
// Check first item.
|
||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||
await firstTodo.getByRole('checkbox').check();
|
||||
await expect(firstTodo).toHaveClass('completed');
|
||||
|
||||
// Check second item.
|
||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
||||
await expect(secondTodo).not.toHaveClass('completed');
|
||||
await secondTodo.getByRole('checkbox').check();
|
||||
|
||||
// Assert completed class.
|
||||
await expect(firstTodo).toHaveClass('completed');
|
||||
await expect(secondTodo).toHaveClass('completed');
|
||||
});
|
||||
|
||||
test('should allow me to un-mark items as complete', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
// Create two items.
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press('Enter');
|
||||
}
|
||||
|
||||
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||
const secondTodo = page.getByTestId('todo-item').nth(1);
|
||||
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
|
||||
|
||||
await firstTodoCheckbox.check();
|
||||
await expect(firstTodo).toHaveClass('completed');
|
||||
await expect(secondTodo).not.toHaveClass('completed');
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
await firstTodoCheckbox.uncheck();
|
||||
await expect(firstTodo).not.toHaveClass('completed');
|
||||
await expect(secondTodo).not.toHaveClass('completed');
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
||||
});
|
||||
|
||||
test('should allow me to edit an item', async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
const secondTodo = todoItems.nth(1);
|
||||
await secondTodo.dblclick();
|
||||
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
|
||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||
|
||||
// Explicitly assert the new text value.
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
'buy some sausages',
|
||||
TODO_ITEMS[2]
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Editing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test('should hide other controls when editing', async ({ page }) => {
|
||||
const todoItem = page.getByTestId('todo-item').nth(1);
|
||||
await todoItem.dblclick();
|
||||
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
|
||||
await expect(todoItem.locator('label', {
|
||||
hasText: TODO_ITEMS[1],
|
||||
})).not.toBeVisible();
|
||||
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
});
|
||||
|
||||
test('should save edits on blur', async ({ page }) => {
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
|
||||
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
'buy some sausages',
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
});
|
||||
|
||||
test('should trim entered text', async ({ page }) => {
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
'buy some sausages',
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
});
|
||||
|
||||
test('should remove the item if an empty text string was entered', async ({ page }) => {
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||
|
||||
await expect(todoItems).toHaveText([
|
||||
TODO_ITEMS[0],
|
||||
TODO_ITEMS[2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should cancel edits on escape', async ({ page }) => {
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
await todoItems.nth(1).dblclick();
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
|
||||
await expect(todoItems).toHaveText(TODO_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Counter', () => {
|
||||
test('should display the current number of todo items', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
// create a todo count locator
|
||||
const todoCount = page.getByTestId('todo-count')
|
||||
|
||||
await newTodo.fill(TODO_ITEMS[0]);
|
||||
await newTodo.press('Enter');
|
||||
|
||||
await expect(todoCount).toContainText('1');
|
||||
|
||||
await newTodo.fill(TODO_ITEMS[1]);
|
||||
await newTodo.press('Enter');
|
||||
await expect(todoCount).toContainText('2');
|
||||
|
||||
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Clear completed button', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
});
|
||||
|
||||
test('should display the correct text', async ({ page }) => {
|
||||
await page.locator('.todo-list li .toggle').first().check();
|
||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should remove completed items when clicked', async ({ page }) => {
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
await todoItems.nth(1).getByRole('checkbox').check();
|
||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
||||
await expect(todoItems).toHaveCount(2);
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
});
|
||||
|
||||
test('should be hidden when there are no items that are completed', async ({ page }) => {
|
||||
await page.locator('.todo-list li .toggle').first().check();
|
||||
await page.getByRole('button', { name: 'Clear completed' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Persistence', () => {
|
||||
test('should persist its data', async ({ page }) => {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press('Enter');
|
||||
}
|
||||
|
||||
const todoItems = page.getByTestId('todo-item');
|
||||
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
|
||||
await firstTodoCheck.check();
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
await expect(firstTodoCheck).toBeChecked();
|
||||
await expect(todoItems).toHaveClass(['completed', '']);
|
||||
|
||||
// Ensure there is 1 completed item.
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
// Now reload.
|
||||
await page.reload();
|
||||
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
await expect(firstTodoCheck).toBeChecked();
|
||||
await expect(todoItems).toHaveClass(['completed', '']);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Routing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await createDefaultTodos(page);
|
||||
// make sure the app had a chance to save updated todos in storage
|
||||
// before navigating to a new view, otherwise the items can get lost :(
|
||||
// in some frameworks like Durandal
|
||||
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
||||
});
|
||||
|
||||
test('should allow me to display active items', async ({ page }) => {
|
||||
const todoItem = page.getByTestId('todo-item');
|
||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole('link', { name: 'Active' }).click();
|
||||
await expect(todoItem).toHaveCount(2);
|
||||
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
});
|
||||
|
||||
test('should respect the back button', async ({ page }) => {
|
||||
const todoItem = page.getByTestId('todo-item');
|
||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
await test.step('Showing all items', async () => {
|
||||
await page.getByRole('link', { name: 'All' }).click();
|
||||
await expect(todoItem).toHaveCount(3);
|
||||
});
|
||||
|
||||
await test.step('Showing active items', async () => {
|
||||
await page.getByRole('link', { name: 'Active' }).click();
|
||||
});
|
||||
|
||||
await test.step('Showing completed items', async () => {
|
||||
await page.getByRole('link', { name: 'Completed' }).click();
|
||||
});
|
||||
|
||||
await expect(todoItem).toHaveCount(1);
|
||||
await page.goBack();
|
||||
await expect(todoItem).toHaveCount(2);
|
||||
await page.goBack();
|
||||
await expect(todoItem).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('should allow me to display completed items', async ({ page }) => {
|
||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole('link', { name: 'Completed' }).click();
|
||||
await expect(page.getByTestId('todo-item')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('should allow me to display all items', async ({ page }) => {
|
||||
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
await page.getByRole('link', { name: 'Active' }).click();
|
||||
await page.getByRole('link', { name: 'Completed' }).click();
|
||||
await page.getByRole('link', { name: 'All' }).click();
|
||||
await expect(page.getByTestId('todo-item')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('should highlight the currently applied filter', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
|
||||
|
||||
//create locators for active and completed links
|
||||
const activeLink = page.getByRole('link', { name: 'Active' });
|
||||
const completedLink = page.getByRole('link', { name: 'Completed' });
|
||||
await activeLink.click();
|
||||
|
||||
// Page change - active items.
|
||||
await expect(activeLink).toHaveClass('selected');
|
||||
await completedLink.click();
|
||||
|
||||
// Page change - completed items.
|
||||
await expect(completedLink).toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
|
||||
async function createDefaultTodos(page: Page) {
|
||||
// create a new todo locator
|
||||
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||
|
||||
for (const item of TODO_ITEMS) {
|
||||
await newTodo.fill(item);
|
||||
await newTodo.press('Enter');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
||||
return await page.waitForFunction(e => {
|
||||
return JSON.parse(localStorage['react-todos']).length === e;
|
||||
}, expected);
|
||||
}
|
||||
|
||||
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
||||
return await page.waitForFunction(e => {
|
||||
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
|
||||
}, expected);
|
||||
}
|
||||
|
||||
async function checkTodosInLocalStorage(page: Page, title: string) {
|
||||
return await page.waitForFunction(t => {
|
||||
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
|
||||
}, title);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue