On Deck + Misc Fixes and Changes (#1215)

* Added playwright and started writing e2e tests.

* To make things easy, disabled other browsers while I get confortable. Added a login flow (assumes my dev env)

* More tests on login page

* Lots more testing code, trying to figure out auth code.

* Ensure we don't track DBs inside config

* Added a new date property for when chapters are added to a series which helps with OnDeck calculations. Changed a lot of heavy api calls to use IEnumerable to stream repsonse to UI.

* Fixed OnDeck with a new field for when last chapter was added on Series. This is a streamlined way to query.

Updated Reading List with NormalizedTitle, CoverImage, CoverImageLocked.

* Implemented the ability to read a random item in the reading list and for the reading list to be intact for order.

* Tweaked the style for webtoon to not span the whole width, but use max width

* When we update a cover image just send an event so we don't need to have logic for when updates occur

* Fixed a bad name for entity type on cover updates

* Aligned the edit collection tag modal to align with new tab design

* Rewrote code for picking the first file for metadata to ensure it always picks the correct file, esp if the first chapter of a series starts with a float (1.1)

* Refactored setting LastChapterAdded to ensure we do it on the Series.

* Updated Chapter updating in scan loop to avoid nested for loop and an additional loop.

* Fixed a bug where locked person fields wouldn't persist between scans.

* Updated Contributing to reflect how to view the swagger api
This commit is contained in:
Joseph Milazzo 2022-04-11 17:43:40 -05:00 committed by GitHub
parent 912dfa8a80
commit 3bbb02f574
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 3397 additions and 343 deletions

27
UI/Web/.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14.x'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v2
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

3
UI/Web/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/

View file

@ -20,7 +20,9 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
~~Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).~~
Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests.
## Further help

View file

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

View file

@ -0,0 +1,398 @@
// import { test, expect, 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'
// ];
// test.describe('New Todo', () => {
// test('should allow me to add todo items', async ({ page }) => {
// // Create 1st todo.
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
// await page.locator('.new-todo').press('Enter');
// // Make sure the list only has one todo item.
// await expect(page.locator('.view label')).toHaveText([
// TODO_ITEMS[0]
// ]);
// // Create 2nd todo.
// await page.locator('.new-todo').fill(TODO_ITEMS[1]);
// await page.locator('.new-todo').press('Enter');
// // Make sure the list now has two todo items.
// await expect(page.locator('.view label')).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 one todo item.
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
// await page.locator('.new-todo').press('Enter');
// // Check that input is empty.
// await expect(page.locator('.new-todo')).toBeEmpty();
// await checkNumberOfTodosInLocalStorage(page, 1);
// });
// test('should append new items to the bottom of the list', async ({ page }) => {
// // Create 3 items.
// await createDefaultTodos(page);
// // Check test using different methods.
// await expect(page.locator('.todo-count')).toHaveText('3 items left');
// await expect(page.locator('.todo-count')).toContainText('3');
// await expect(page.locator('.todo-count')).toHaveText(/3/);
// // Check all items in one call.
// await expect(page.locator('.view label')).toHaveText(TODO_ITEMS);
// await checkNumberOfTodosInLocalStorage(page, 3);
// });
// test('should show #main and #footer when items added', async ({ page }) => {
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
// await page.locator('.new-todo').press('Enter');
// await expect(page.locator('.main')).toBeVisible();
// await expect(page.locator('.footer')).toBeVisible();
// await checkNumberOfTodosInLocalStorage(page, 1);
// });
// });
// 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.locator('.toggle-all').check();
// // Ensure all todos have 'completed' class.
// await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']);
// await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// });
// test('should allow me to clear the complete state of all items', async ({ page }) => {
// // Check and then immediately uncheck.
// await page.locator('.toggle-all').check();
// await page.locator('.toggle-all').uncheck();
// // Should be no completed classes.
// await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']);
// });
// test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
// const toggleAll = page.locator('.toggle-all');
// await toggleAll.check();
// await expect(toggleAll).toBeChecked();
// await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// // Uncheck first todo.
// const firstTodo = page.locator('.todo-list li').nth(0);
// await firstTodo.locator('.toggle').uncheck();
// // Reuse toggleAll locator and make sure its not checked.
// await expect(toggleAll).not.toBeChecked();
// await firstTodo.locator('.toggle').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 two items.
// for (const item of TODO_ITEMS.slice(0, 2)) {
// await page.locator('.new-todo').fill(item);
// await page.locator('.new-todo').press('Enter');
// }
// // Check first item.
// const firstTodo = page.locator('.todo-list li').nth(0);
// await firstTodo.locator('.toggle').check();
// await expect(firstTodo).toHaveClass('completed');
// // Check second item.
// const secondTodo = page.locator('.todo-list li').nth(1);
// await expect(secondTodo).not.toHaveClass('completed');
// await secondTodo.locator('.toggle').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 two items.
// for (const item of TODO_ITEMS.slice(0, 2)) {
// await page.locator('.new-todo').fill(item);
// await page.locator('.new-todo').press('Enter');
// }
// const firstTodo = page.locator('.todo-list li').nth(0);
// const secondTodo = page.locator('.todo-list li').nth(1);
// await firstTodo.locator('.toggle').check();
// await expect(firstTodo).toHaveClass('completed');
// await expect(secondTodo).not.toHaveClass('completed');
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// await firstTodo.locator('.toggle').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.locator('.todo-list li');
// const secondTodo = todoItems.nth(1);
// await secondTodo.dblclick();
// await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]);
// await secondTodo.locator('.edit').fill('buy some sausages');
// await secondTodo.locator('.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.locator('.todo-list li').nth(1);
// await todoItem.dblclick();
// await expect(todoItem.locator('.toggle')).not.toBeVisible();
// await expect(todoItem.locator('label')).not.toBeVisible();
// await checkNumberOfTodosInLocalStorage(page, 3);
// });
// test('should save edits on blur', async ({ page }) => {
// const todoItems = page.locator('.todo-list li');
// await todoItems.nth(1).dblclick();
// await todoItems.nth(1).locator('.edit').fill('buy some sausages');
// await todoItems.nth(1).locator('.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.locator('.todo-list li');
// await todoItems.nth(1).dblclick();
// await todoItems.nth(1).locator('.edit').fill(' buy some sausages ');
// await todoItems.nth(1).locator('.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.locator('.todo-list li');
// await todoItems.nth(1).dblclick();
// await todoItems.nth(1).locator('.edit').fill('');
// await todoItems.nth(1).locator('.edit').press('Enter');
// await expect(todoItems).toHaveText([
// TODO_ITEMS[0],
// TODO_ITEMS[2],
// ]);
// });
// test('should cancel edits on escape', async ({ page }) => {
// const todoItems = page.locator('.todo-list li');
// await todoItems.nth(1).dblclick();
// await todoItems.nth(1).locator('.edit').press('Escape');
// await expect(todoItems).toHaveText(TODO_ITEMS);
// });
// });
// test.describe('Counter', () => {
// test('should display the current number of todo items', async ({ page }) => {
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
// await page.locator('.new-todo').press('Enter');
// await expect(page.locator('.todo-count')).toContainText('1');
// await page.locator('.new-todo').fill(TODO_ITEMS[1]);
// await page.locator('.new-todo').press('Enter');
// await expect(page.locator('.todo-count')).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.locator('.clear-completed')).toHaveText('Clear completed');
// });
// test('should remove completed items when clicked', async ({ page }) => {
// const todoItems = page.locator('.todo-list li');
// await todoItems.nth(1).locator('.toggle').check();
// await page.locator('.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.locator('.clear-completed').click();
// await expect(page.locator('.clear-completed')).toBeHidden();
// });
// });
// test.describe('Persistence', () => {
// test('should persist its data', async ({ page }) => {
// for (const item of TODO_ITEMS.slice(0, 2)) {
// await page.locator('.new-todo').fill(item);
// await page.locator('.new-todo').press('Enter');
// }
// const todoItems = page.locator('.todo-list li');
// await todoItems.nth(0).locator('.toggle').check();
// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
// await expect(todoItems).toHaveClass(['completed', '']);
// // Ensure there is 1 completed item.
// checkNumberOfCompletedTodosInLocalStorage(page, 1);
// // Now reload.
// await page.reload();
// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
// 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 }) => {
// await page.locator('.todo-list li .toggle').nth(1).check();
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// await page.locator('.filters >> text=Active').click();
// await expect(page.locator('.todo-list li')).toHaveCount(2);
// await expect(page.locator('.todo-list li')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
// });
// test('should respect the back button', async ({ page }) => {
// await page.locator('.todo-list li .toggle').nth(1).check();
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// await test.step('Showing all items', async () => {
// await page.locator('.filters >> text=All').click();
// await expect(page.locator('.todo-list li')).toHaveCount(3);
// });
// await test.step('Showing active items', async () => {
// await page.locator('.filters >> text=Active').click();
// });
// await test.step('Showing completed items', async () => {
// await page.locator('.filters >> text=Completed').click();
// });
// await expect(page.locator('.todo-list li')).toHaveCount(1);
// await page.goBack();
// await expect(page.locator('.todo-list li')).toHaveCount(2);
// await page.goBack();
// await expect(page.locator('.todo-list li')).toHaveCount(3);
// });
// test('should allow me to display completed items', async ({ page }) => {
// await page.locator('.todo-list li .toggle').nth(1).check();
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// await page.locator('.filters >> text=Completed').click();
// await expect(page.locator('.todo-list li')).toHaveCount(1);
// });
// test('should allow me to display all items', async ({ page }) => {
// await page.locator('.todo-list li .toggle').nth(1).check();
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// await page.locator('.filters >> text=Active').click();
// await page.locator('.filters >> text=Completed').click();
// await page.locator('.filters >> text=All').click();
// await expect(page.locator('.todo-list li')).toHaveCount(3);
// });
// test('should highlight the currently applied filter', async ({ page }) => {
// await expect(page.locator('.filters >> text=All')).toHaveClass('selected');
// await page.locator('.filters >> text=Active').click();
// // Page change - active items.
// await expect(page.locator('.filters >> text=Active')).toHaveClass('selected');
// await page.locator('.filters >> text=Completed').click();
// // Page change - completed items.
// await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected');
// });
// });
// async function createDefaultTodos(page: Page) {
// for (const item of TODO_ITEMS) {
// await page.locator('.new-todo').fill(item);
// await page.locator('.new-todo').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(i => i.completed).length === e;
// }, expected);
// }
// async function checkTodosInLocalStorage(page: Page, title: string) {
// return await page.waitForFunction(t => {
// return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t);
// }, title);
// }

View file

@ -1,23 +0,0 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', async () => {
await page.navigateTo();
expect(await page.getTitleText()).toEqual('kavita-webui app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

View file

@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('When not authenticated, should be redirected to login page', async ({ page }) => {
await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
expect(page.url()).toBe('http://localhost:4200/login');
});
test('When not authenticated, should be redirected to login page from an authenticated page', async ({ page }) => {
await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' });
expect(page.url()).toBe('http://localhost:4200/login');
});
// Not sure how to test when we need localStorage: https://github.com/microsoft/playwright/issues/6258
// test('When authenticated, should be redirected to library page', async ({ page }) => {
// await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
// console.log('url: ', page.url());
// expect(page.url()).toBe('http://localhost:4200/library');
// });

View file

@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
test('When not authenticated, should be redirected to login page', async ({ page }) => {
await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
expect(page.url()).toBe('http://localhost:4200/login');
});
test('Should be able to log in', async ({ page }) => {
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
const username = page.locator('#username');
expect(username).toBeEditable();
const password = page.locator('#password');
expect(password).toBeEditable();
await username.type('Joe');
await password.type('P4ssword');
const button = page.locator('button[type="submit"]');
await button.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(200);
expect(page.url()).toBe('http://localhost:4200/library');
});
test('Should get a toastr when no username', async ({ page }) => {
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
const username = page.locator('#username');
expect(username).toBeEditable();
await username.type('');
const button = page.locator('button[type="submit"]');
await button.click();
await page.waitForTimeout(100);
const toastr = page.locator('#toast-container div[role="alertdialog"]')
await expect(toastr).toHaveText('Invalid username');
expect(page.url()).toBe('http://localhost:4200/login');
});

View file

@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test('When on login page, clicking Forgot Password should redirect', async ({ page }) => {
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
await page.click('a[routerlink="/registration/reset-password"]')
await page.waitForLoadState('networkidle');
expect(page.url()).toBe('http://localhost:4200/registration/reset-password');
});
test('Going directly to reset url should stay on the page', async ({page}) => {
await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' });
const email = page.locator('#email');
expect(email).toBeEditable();
})
test('Submitting an email, should give a prompt to user, redirect back to login', async ({ page }) => {
await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' });
const email = page.locator('#email');
expect(email).toBeEditable();
await email.type('XXX@gmail.com');
const button = page.locator('button[type="submit"]');
await button.click();
const toastr = page.locator('#toast-container div[role="alertdialog"]')
await expect(toastr).toHaveText('An email will be sent to the email if it exists in our database');
await page.waitForLoadState('networkidle');
expect(page.url()).toBe('http://localhost:4200/login');
});

View file

@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test.use({ storageState: 'storage/admin.json' });
test('When on login page, side nav should not render', async ({ page }) => {
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
await expect(page.locator(".side-nav")).toHaveCount(0)
});
test('When on library page, side nav should render', async ({ page }) => {
await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' });
await expect(page.locator(".side-nav")).toHaveCount(1)
});

46
UI/Web/global-setup.ts Normal file
View file

@ -0,0 +1,46 @@
import { Browser, chromium, FullConfig, request } from '@playwright/test';
async function globalSetup(config: FullConfig) {
let requestContext = await request.newContext();
var token = await requestContext.post('http://localhost:5000/account/login', {
form: {
'user': 'Joe',
'password': 'P4ssword'
}
});
console.log(token.json());
// Save signed-in state to 'storageState.json'.
//await requestContext.storageState({ path: 'adminStorageState.json' });
await requestContext.dispose();
requestContext = await request.newContext();
await requestContext.post('http://localhost:5000/account/login', {
form: {
'user': 'nonadmin',
'password': 'P4ssword'
}
});
// Save signed-in state to 'storageState.json'.
//await requestContext.storageState({ path: 'nonAdminStorageState.json' });
await requestContext.dispose();
}
// async function globalSetup (config: FullConfig) {
// const browser = await chromium.launch()
// await saveStorage(browser, 'nonadmin', 'P4ssword', 'storage/user.json')
// await saveStorage(browser, 'Joe', 'P4ssword', 'storage/admin.json')
// await browser.close()
// }
async function saveStorage (browser: Browser, username: string, password: string, saveStoragePath: string) {
const page = await browser.newPage()
await page.goto('http://localhost:5000/account/login')
await page.type('#username', username)
await page.type('#password', password)
await page.click('button[type="submit"]')
await page.context().storageState({ path: saveStoragePath })
}
export default globalSetup;

View file

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

526
UI/Web/package-lock.json generated
View file

@ -1599,6 +1599,17 @@
"@babel/helper-plugin-utils": "^7.16.7"
}
},
"@babel/plugin-transform-typescript": {
"version": "7.16.8",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz",
"integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==",
"dev": true,
"requires": {
"@babel/helper-create-class-features-plugin": "^7.16.7",
"@babel/helper-plugin-utils": "^7.16.7",
"@babel/plugin-syntax-typescript": "^7.16.7"
}
},
"@babel/plugin-transform-unicode-escapes": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz",
@ -1721,6 +1732,17 @@
"esutils": "^2.0.2"
}
},
"@babel/preset-typescript": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz",
"integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.16.7",
"@babel/helper-validator-option": "^7.16.7",
"@babel/plugin-transform-typescript": "^7.16.7"
}
},
"@babel/runtime": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz",
@ -2553,6 +2575,278 @@
"read-package-json-fast": "^2.0.1"
}
},
"@playwright/test": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.20.2.tgz",
"integrity": "sha512-unkLa+xe/lP7MVC0qpgadc9iSG1+LEyGBzlXhGS/vLGAJaSFs8DNfI89hNd5shHjWfNzb34JgPVnkRKCSNo5iw==",
"dev": true,
"requires": {
"@babel/code-frame": "7.16.7",
"@babel/core": "7.16.12",
"@babel/helper-plugin-utils": "7.16.7",
"@babel/plugin-proposal-class-properties": "7.16.7",
"@babel/plugin-proposal-dynamic-import": "7.16.7",
"@babel/plugin-proposal-export-namespace-from": "7.16.7",
"@babel/plugin-proposal-logical-assignment-operators": "7.16.7",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7",
"@babel/plugin-proposal-numeric-separator": "7.16.7",
"@babel/plugin-proposal-optional-chaining": "7.16.7",
"@babel/plugin-proposal-private-methods": "7.16.11",
"@babel/plugin-proposal-private-property-in-object": "7.16.7",
"@babel/plugin-syntax-async-generators": "7.8.4",
"@babel/plugin-syntax-json-strings": "7.8.3",
"@babel/plugin-syntax-object-rest-spread": "7.8.3",
"@babel/plugin-syntax-optional-catch-binding": "7.8.3",
"@babel/plugin-transform-modules-commonjs": "7.16.8",
"@babel/preset-typescript": "7.16.7",
"colors": "1.4.0",
"commander": "8.3.0",
"debug": "4.3.3",
"expect": "27.2.5",
"jest-matcher-utils": "27.2.5",
"json5": "2.2.1",
"mime": "3.0.0",
"minimatch": "3.0.4",
"ms": "2.1.3",
"open": "8.4.0",
"pirates": "4.0.4",
"playwright-core": "1.20.2",
"rimraf": "3.0.2",
"source-map-support": "0.4.18",
"stack-utils": "2.0.5",
"yazl": "2.5.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"dev": true,
"requires": {
"@babel/highlight": "^7.16.7"
}
},
"@babel/core": {
"version": "7.16.12",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz",
"integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.16.8",
"@babel/helper-compilation-targets": "^7.16.7",
"@babel/helper-module-transforms": "^7.16.7",
"@babel/helpers": "^7.16.7",
"@babel/parser": "^7.16.12",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.16.10",
"@babel/types": "^7.16.8",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.1.2",
"semver": "^6.3.0",
"source-map": "^0.5.0"
}
},
"@babel/helper-validator-identifier": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
"integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
"dev": true
},
"@babel/highlight": {
"version": "7.17.9",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz",
"integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.16.7",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"requires": {
"debug": "4"
}
},
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"dev": true
},
"expect": {
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/expect/-/expect-27.2.5.tgz",
"integrity": "sha512-ZrO0w7bo8BgGoP/bLz+HDCI+0Hfei9jUSZs5yI/Wyn9VkG9w8oJ7rHRgYj+MA7yqqFa0IwHA3flJzZtYugShJA==",
"dev": true,
"requires": {
"@jest/types": "^27.2.5",
"ansi-styles": "^5.0.0",
"jest-get-type": "^27.0.6",
"jest-matcher-utils": "^27.2.5",
"jest-message-util": "^27.2.5",
"jest-regex-util": "^27.0.6"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"https-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
"dev": true,
"requires": {
"agent-base": "6",
"debug": "4"
}
},
"jest-matcher-utils": {
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.2.5.tgz",
"integrity": "sha512-qNR/kh6bz0Dyv3m68Ck2g1fLW5KlSOUNcFQh87VXHZwWc/gY6XwnKofx76Qytz3x5LDWT09/2+yXndTkaG4aWg==",
"dev": true,
"requires": {
"chalk": "^4.0.0",
"jest-diff": "^27.2.5",
"jest-get-type": "^27.0.6",
"pretty-format": "^27.2.5"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
}
}
},
"json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"dev": true
},
"mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"dev": true
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"pirates": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz",
"integrity": "sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==",
"dev": true
},
"playwright-core": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz",
"integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==",
"dev": true,
"requires": {
"colors": "1.4.0",
"commander": "8.3.0",
"debug": "4.3.3",
"extract-zip": "2.0.1",
"https-proxy-agent": "5.0.0",
"jpeg-js": "0.4.3",
"mime": "3.0.0",
"pixelmatch": "5.2.1",
"pngjs": "6.0.0",
"progress": "2.0.3",
"proper-lockfile": "4.1.2",
"proxy-from-env": "1.1.0",
"rimraf": "3.0.2",
"socks-proxy-agent": "6.1.1",
"stack-utils": "2.0.5",
"ws": "8.4.2",
"yauzl": "2.10.0",
"yazl": "2.5.1"
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
},
"source-map-support": {
"version": "0.4.18",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz",
"integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==",
"dev": true,
"requires": {
"source-map": "^0.5.6"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"ws": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz",
"integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==",
"dev": true
}
}
},
"@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@ -2919,6 +3213,16 @@
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==",
"dev": true
},
"@types/yauzl": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
"integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==",
"dev": true,
"optional": true,
"requires": {
"@types/node": "*"
}
},
"@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@ -3906,6 +4210,12 @@
"ieee754": "^1.1.13"
}
},
"buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
"dev": true
},
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -4237,6 +4547,12 @@
"integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==",
"dev": true
},
"colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
"dev": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -5113,6 +5429,15 @@
}
}
},
"end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"requires": {
"once": "^1.4.0"
}
},
"enhanced-resolve": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz",
@ -5558,6 +5883,29 @@
"tmp": "^0.0.33"
}
},
"extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"requires": {
"@types/yauzl": "^2.9.1",
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"dependencies": {
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"requires": {
"pump": "^3.0.0"
}
}
}
},
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
@ -5639,6 +5987,15 @@
"bser": "2.1.1"
}
},
"fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
"dev": true,
"requires": {
"pend": "~1.2.0"
}
},
"fetch-cookie": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz",
@ -8301,6 +8658,12 @@
}
}
},
"jpeg-js": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
"integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==",
"dev": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -9897,6 +10260,12 @@
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true
},
"pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
"dev": true
},
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@ -9955,6 +10324,23 @@
"nice-napi": "^1.0.2"
}
},
"pixelmatch": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz",
"integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==",
"dev": true,
"requires": {
"pngjs": "^4.0.1"
},
"dependencies": {
"pngjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz",
"integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==",
"dev": true
}
}
},
"pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@ -9964,6 +10350,86 @@
"find-up": "^4.0.0"
}
},
"playwright": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.20.2.tgz",
"integrity": "sha512-p6GE8A/f2G7t8FIk/AwQ94nT7R7tyPRJyKt1FwRjwBDf4WdpgoAr4hDfMgHy+CkClR22adFjopGwhxXAPsewhg==",
"dev": true,
"requires": {
"playwright-core": "1.20.2"
},
"dependencies": {
"agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"requires": {
"debug": "4"
}
},
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"dev": true
},
"https-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
"dev": true,
"requires": {
"agent-base": "6",
"debug": "4"
}
},
"mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"dev": true
},
"playwright-core": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz",
"integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==",
"dev": true,
"requires": {
"colors": "1.4.0",
"commander": "8.3.0",
"debug": "4.3.3",
"extract-zip": "2.0.1",
"https-proxy-agent": "5.0.0",
"jpeg-js": "0.4.3",
"mime": "3.0.0",
"pixelmatch": "5.2.1",
"pngjs": "6.0.0",
"progress": "2.0.3",
"proper-lockfile": "4.1.2",
"proxy-from-env": "1.1.0",
"rimraf": "3.0.2",
"socks-proxy-agent": "6.1.1",
"stack-utils": "2.0.5",
"ws": "8.4.2",
"yauzl": "2.10.0",
"yazl": "2.5.1"
}
},
"ws": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz",
"integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==",
"dev": true
}
}
},
"pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"dev": true
},
"portfinder": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",
@ -10366,6 +10832,12 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true
},
"promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@ -10400,6 +10872,25 @@
"sisteransi": "^1.0.5"
}
},
"proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
},
"dependencies": {
"retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=",
"dev": true
}
}
},
"protractor": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz",
@ -10761,6 +11252,12 @@
}
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@ -10773,6 +11270,16 @@
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
},
"pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
"dev": true,
"requires": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -12953,6 +13460,25 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz",
"integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA=="
},
"yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
"dev": true,
"requires": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"yazl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
"integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
"dev": true,
"requires": {
"buffer-crc32": "~0.2.3"
}
},
"yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View file

@ -49,12 +49,14 @@
"@angular-devkit/build-angular": "~13.2.3",
"@angular/cli": "^13.2.3",
"@angular/compiler-cli": "~13.2.2",
"@playwright/test": "^1.20.2",
"@types/jest": "^27.4.0",
"@types/node": "^17.0.17",
"codelyzer": "^6.0.2",
"jest": "^27.5.1",
"jest-preset-angular": "^11.1.0",
"karma-coverage": "~2.2.0",
"playwright": "^1.20.2",
"protractor": "~7.0.0",
"ts-node": "~10.5.0",
"tslint": "^6.1.3",

106
UI/Web/playwright.config.ts Normal file
View file

@ -0,0 +1,106 @@
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* 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: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:4200',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
globalSetup: require.resolve('./global-setup'),
/* 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: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
};
export default config;

View file

@ -19,5 +19,6 @@ export interface ReadingList {
title: string;
summary: string;
promoted: boolean;
coverImageLocked: boolean;
items: Array<ReadingListItem>;
}

View file

@ -32,7 +32,7 @@ export interface SeriesMetadata {
tagsLocked: boolean;
writersLocked: boolean;
coverArtistsLocked: boolean;
publishersLocked: boolean;
publisherLocked: boolean;
charactersLocked: boolean;
pencillersLocked: boolean;
inkersLocked: boolean;

View file

@ -460,7 +460,7 @@ export class ActionService implements OnDestroy {
}
editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) {
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' });
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' });
readingListModalRef.componentInstance.readingList = readingList;
readingListModalRef.closed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {

View file

@ -75,6 +75,10 @@ export class ImageService implements OnDestroy {
return this.baseUrl + 'image/collection-cover?collectionTagId=' + collectionTagId;
}
getReadingListCoverImage(readingListId: number) {
return this.baseUrl + 'image/readinglist-cover?readingListId=' + readingListId;
}
getChapterCoverImage(chapterId: number) {
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId;
}

View file

@ -133,9 +133,6 @@ export class SeriesService {
getRecentlyUpdatedSeries() {
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
}
getRecentlyAddedChapters() {
return this.httpClient.post<RecentlyAddedItem[]>(this.baseUrl + 'series/recently-added-chapters', {});
}
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.createSeriesFilter(filter);

View file

@ -30,6 +30,10 @@ export class UploadService {
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)});
}
updateReadingListCoverImage(readingListId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)});
}
updateChapterCoverImage(chapterId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
}

View file

@ -130,7 +130,7 @@
<span class="d-none d-sm-block">&nbsp;{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}}</span>
</button>
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="d-none d-sm-block">&nbsp;Go Back</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="d-none d-sm-block">Settings {{drawerOpen}}</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="d-none d-sm-block">Settings</span></button>
<div class="book-title col-2 d-none d-sm-block">
<ng-container *ngIf="isLoading; else showTitle">
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">

View file

@ -5,16 +5,15 @@
</button>
</div>
<div class="modal-body">
<p>
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<p>
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<form [formGroup]="collectionTagForm">
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
@ -62,7 +61,7 @@
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<div [ngbNavOutlet]="nav" class="mt-3 ms-2"></div>
</div>
<div class="modal-footer">

View file

@ -4,6 +4,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin } from 'rxjs';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination';
@ -14,6 +15,7 @@ import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-edit-collection-tags',
templateUrl: './edit-collection-tags.component.html',
@ -39,11 +41,15 @@ export class EditCollectionTagsComponent implements OnInit {
return this.selections != null && this.selections.hasSomeSelected();
}
get Breakpoint() {
return Breakpoint;
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService) { }
private imageService: ImageService, private uploadService: UploadService,
public utilityService: UtilityService) { }
ngOnInit(): void {
if (this.pagination == undefined) {

View file

@ -192,8 +192,8 @@
<div class="mb-3">
<label for="publisher" class="form-label">Publisher</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
(newItemAdded)="metadata.publisherLocked = true" (selectedData)="metadata.publisherLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>

View file

@ -15,7 +15,6 @@
</ng-template>
</app-carousel-reel>
<!-- TODO: Refactor this so we can use series actions here -->
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
@ -27,11 +26,4 @@
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
</ng-template>
</app-carousel-reel>
<!-- <app-carousel-reel [items]="recentlyAddedChapters" title="Recently Added" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.title" [subtitle]="item.seriesName" [imageUrl]="imageService.getRecentlyAddedItem(item)"
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)"></app-card-item>
</ng-template>
</app-carousel-reel> -->
</app-carousel-reel>

View file

@ -29,7 +29,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
isAdmin = false;
recentlyUpdatedSeries: SeriesGroup[] = [];
recentlyAddedChapters: RecentlyAddedItem[] = [];
inProgress: Series[] = [];
recentlyAddedSeries: Series[] = [];
@ -57,7 +56,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
} else if (res.event === EVENTS.ScanSeries) {
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
this.loadRecentlyAdded$.next();
@ -125,10 +123,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
this.seriesService.getRecentlyUpdatedSeries().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => {
this.recentlyUpdatedSeries = updatedSeries;
});
this.seriesService.getRecentlyAddedChapters().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => {
this.recentlyAddedChapters = updatedSeries;
});
}
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {

View file

@ -27,13 +27,10 @@
img, .full-width {
width: 100% !important;
max-width: 100% !important;
height: auto;
}
// .img-container {
// overflow: auto;
// }
@keyframes move-up-down {

View file

@ -1,26 +1,41 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<p>
This list is currently {{readingList?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
</p>
<form [formGroup]="reviewGroup">
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<input id="title" class="form-control" formControlName="title" type="text">
</div>
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0].title}}</a>
<ng-template ngbNavContent>
<p>
This list is currently {{readingList?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
</p>
<form [formGroup]="reviewGroup">
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<input id="title" class="form-control" formControlName="title" type="text">
</div>
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1].title}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="readingList.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="ms-2 mt-3"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>

View file

@ -1,8 +1,14 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin } from 'rxjs';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { ReadingList } from 'src/app/_models/reading-list';
import { ImageService } from 'src/app/_services/image.service';
import { ReadingListService } from 'src/app/_services/reading-list.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-edit-reading-list-modal',
@ -14,13 +20,33 @@ export class EditReadingListModalComponent implements OnInit {
@Input() readingList!: ReadingList;
reviewGroup!: FormGroup;
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService) { }
coverImageIndex: number = 0;
/**
* Url of the selected cover
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
imageUrls: Array<string> = [];
tabs = [{title: 'General', disabled: false}, {title: 'Cover', disabled: false}];
active = this.tabs[0];
get Breakpoint() {
return Breakpoint;
}
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService,
public utilityService: UtilityService, private uploadService: UploadService, private toastr: ToastrService,
private imageService: ImageService) { }
ngOnInit(): void {
this.reviewGroup = new FormGroup({
title: new FormControl(this.readingList.title, [Validators.required]),
summary: new FormControl(this.readingList.summary, [])
});
this.imageUrls.push(this.imageService.randomize(this.imageService.getReadingListCoverImage(this.readingList.id)));
}
close() {
@ -29,12 +55,22 @@ export class EditReadingListModalComponent implements OnInit {
save() {
if (this.reviewGroup.value.title.trim() === '') return;
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted};
this.readingListService.update(model).subscribe(() => {
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
const apis = [this.readingListService.update(model)];
if (this.selectedCover !== '') {
apis.push(this.uploadService.updateReadingListCoverImage(this.readingList.id, this.selectedCover))
}
forkJoin(apis).subscribe(results => {
this.readingList.title = model.title;
this.readingList.summary = model.summary;
this.readingList.coverImageLocked = this.coverImageLocked;
this.ngModal.close(this.readingList);
this.toastr.success('Reading List updated');
});
}
@ -49,4 +85,16 @@ export class EditReadingListModalComponent implements OnInit {
});
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
}
}

View file

@ -3,42 +3,48 @@
<span *ngIf="actions.length > 0">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>
</span>
{{readingList.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>&nbsp;
<span class="badge bg-primary rounded-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
{{readingList?.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
</h2>
<h6 subtitle>{{items.length}} Items</h6>
</app-side-nav-companion-bar>
<div class="container-fluid mt-2" *ngIf="readingList">
<div class="mb-3">
<!-- Action row-->
<div class="row g-0">
<div class="col-auto me-2">
<button class="btn btn-primary" title="Read" (click)="read()">
<span>
<i class="fa fa-book-open" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;Read</span>
</span>
</button>
</div>
<div class="col-auto">
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList?.promoted && !this.isAdmin">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="300px" [imageUrl]="readingListImage"></app-image>
</div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<div class="row g-0 mb-3">
<div class="col-auto me-2">
<!-- Action row-->
<button class="btn btn-primary" title="Read" (click)="read()">
<span>
<i class="fa fa-check"></i>
<i class="fa fa-book-open" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;Read</span> <!-- IDEA: We can provide them the ability to read/continue like we do with a series -->
</span>
<span class="read-btn--text">&nbsp;Remove Read</span>
</button>
</div>
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibilit-mode" [value]="accessibilityMode" (change)="accessibilityMode = !accessibilityMode">
<label class="form-check-label" for="accessibilit-mode">Order Numbers</label>
</div>
<div class="col-auto">
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList?.promoted && !this.isAdmin">
<span>
<i class="fa fa-check"></i>
</span>
<span class="read-btn--text">&nbsp;Remove Read</span>
</button>
</div>
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibilit-mode" [value]="accessibilityMode" (change)="accessibilityMode = !accessibilityMode">
<label class="form-check-label" for="accessibilit-mode">Order Numbers</label>
</div>
</div>
</div>
<!-- Summary row-->
<div class="row g-0 mt-2">
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
</div>
</div>
<!-- Summary row-->
<div class="row g-0 mt-2">
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
</div>
</div>
<div *ngIf="items.length === 0">
No chapters added
@ -63,6 +69,8 @@
<span *ngIf="item.promoted">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
</span>
<br/>
<a href="javascript:void(0);" (click)="readChapter(item)">Read</a>
</div>
</div>
</ng-template>

View file

@ -15,6 +15,7 @@ import { ReadingListService } from 'src/app/_services/reading-list.service';
import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component';
import { LibraryService } from '../../_services/library.service';
import { forkJoin } from 'rxjs';
import { ReaderService } from 'src/app/_services/reader.service';
@Component({
selector: 'app-reading-list-detail',
@ -38,6 +39,8 @@ export class ReadingListDetailComponent implements OnInit {
libraryTypes: {[key: number]: LibraryType} = {};
readingListImage: string = '';
get MangaFormat(): typeof MangaFormat {
return MangaFormat;
}
@ -45,7 +48,7 @@ export class ReadingListDetailComponent implements OnInit {
constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService,
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService) {}
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService) {}
ngOnInit(): void {
const listId = this.route.snapshot.paramMap.get('id');
@ -57,6 +60,8 @@ export class ReadingListDetailComponent implements OnInit {
this.listId = parseInt(listId, 10);
this.readingListImage = this.imageService.randomize(this.imageService.getReadingListCoverImage(this.listId));
this.libraryService.getLibraries().subscribe(libs => {
});
@ -107,6 +112,15 @@ export class ReadingListDetailComponent implements OnInit {
}
}
readChapter(item: ReadingListItem) {
let reader = 'manga';
if (item.seriesFormat === MangaFormat.EPUB) {
reader = 'book;'
}
const params = this.readerService.getQueryParamsObject(false, true, this.readingList.id);
this.router.navigate(['library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], {queryParams: params});
}
handleReadingListActionCallback(action: Action, readingList: ReadingList) {
switch(action) {
case Action.Delete:

View file

@ -12,6 +12,7 @@ import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal
import { PipeModule } from '../pipe/pipe.module';
import { SharedModule } from '../shared/shared.module';
import { SidenavModule } from '../sidenav/sidenav.module';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
@ -31,7 +32,8 @@ import { SidenavModule } from '../sidenav/sidenav.module';
CardsModule,
PipeModule,
SharedModule,
SidenavModule
SidenavModule,
NgbNavModule
],
exports: [
AddToListModalComponent,

View file

@ -15,6 +15,6 @@
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="getActions(item)" [supressLibraryLink]="true" [imageUrl]="imageService.placeholderImage" (clicked)="handleClick(item)"></app-card-item>
<app-card-item [title]="item.title" [entity]="item" [actions]="getActions(item)" [supressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)" (clicked)="handleClick(item)"></app-card-item>
</ng-template>
</app-card-detail-layout>

View file

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

4
UI/Web/storage/user.json Normal file
View file

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}