Merged v0.5.1 develop into main.
This commit is contained in:
commit
150479e755
256 changed files with 6898 additions and 1833 deletions
35
UI/Web/package-lock.json
generated
35
UI/Web/package-lock.json
generated
|
@ -3255,11 +3255,6 @@
|
|||
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=",
|
||||
"dev": true
|
||||
},
|
||||
"angular-ng-autocomplete": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/angular-ng-autocomplete/-/angular-ng-autocomplete-2.0.5.tgz",
|
||||
"integrity": "sha512-mYALrzwc5eoFR5xz/diup5GDsxqXp3L707P4CkiBl5l01fKej0nyIDTQ+xXtZUK3spXIyfuOX0ypa9wTrgCP5A=="
|
||||
},
|
||||
"ansi-colors": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
|
||||
|
@ -10645,9 +10640,33 @@
|
|||
"dev": true
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
|
||||
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=",
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.10.0",
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"@ngx-lite/nav-drawer": "^0.4.6",
|
||||
"@ngx-lite/util": "0.0.0",
|
||||
"@types/file-saver": "^2.0.1",
|
||||
"angular-ng-autocomplete": "^2.0.5",
|
||||
"bootstrap": "^4.5.0",
|
||||
"bowser": "^2.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
|
|
|
@ -62,9 +62,15 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
if (Array.isArray(error.error)) {
|
||||
const modalStateErrors: any[] = [];
|
||||
if (error.error.length > 0 && error.error[0].hasOwnProperty('message')) {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.details);
|
||||
});
|
||||
if (error.error[0].details === null) {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.message);
|
||||
});
|
||||
} else {
|
||||
error.error.forEach((issue: {status: string, details: string, message: string}) => {
|
||||
modalStateErrors.push(issue.details);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
error.error.forEach((issue: {code: string, description: string}) => {
|
||||
modalStateErrors.push(issue.description);
|
||||
|
@ -83,6 +89,10 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
} else {
|
||||
console.error('error:', error);
|
||||
if (error.statusText === 'Bad Request') {
|
||||
if (error.error instanceof Blob) {
|
||||
this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status);
|
||||
return;
|
||||
}
|
||||
this.toastr.error(error.error, error.status);
|
||||
} else {
|
||||
this.toastr.error(error.statusText === 'OK' ? error.error : error.statusText, error.status);
|
||||
|
@ -101,7 +111,13 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
console.log('500 error: ', error);
|
||||
}
|
||||
this.toastr.error(err.message);
|
||||
} else {
|
||||
} else if (error.hasOwnProperty('message') && error.message.trim() !== '') {
|
||||
if (error.message != 'User is not authenticated') {
|
||||
console.log('500 error: ', error);
|
||||
}
|
||||
this.toastr.error(error.message);
|
||||
}
|
||||
else {
|
||||
this.toastr.error('There was an unknown critical error.');
|
||||
console.error('500 error:', error);
|
||||
}
|
||||
|
|
7
UI/Web/src/app/_models/events/cover-update-event.ts
Normal file
7
UI/Web/src/app/_models/events/cover-update-event.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Represents a generic cover update event. Id is used based on entityType
|
||||
*/
|
||||
export interface CoverUpdateEvent {
|
||||
id: number;
|
||||
entityType: 'series' | 'chapter' | 'volume' | 'collectionTag';
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
export interface InProgressChapter {
|
||||
id: number;
|
||||
range: string;
|
||||
number: string;
|
||||
pages: number;
|
||||
volumeId: number;
|
||||
pagesRead: number;
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
coverImage: string;
|
||||
libraryId: number;
|
||||
libraryName: string;
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import { Library } from './library';
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
lastActive: string; // datetime
|
||||
created: string; // datetime
|
||||
isAdmin: boolean;
|
||||
//isAdmin: boolean;
|
||||
roles: string[];
|
||||
libraries: Library[];
|
||||
}
|
13
UI/Web/src/app/_models/recently-added-item.ts
Normal file
13
UI/Web/src/app/_models/recently-added-item.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { LibraryType } from "./library";
|
||||
|
||||
export interface RecentlyAddedItem {
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
created: string;
|
||||
title: string;
|
||||
libraryId: number;
|
||||
libraryType: LibraryType;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
id: number; // This is UI only, sent from backend but has no relation to any entity
|
||||
}
|
23
UI/Web/src/app/_models/search/search-result-group.ts
Normal file
23
UI/Web/src/app/_models/search/search-result-group.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Library } from "../library";
|
||||
import { SearchResult } from "../search-result";
|
||||
import { Tag } from "../tag";
|
||||
|
||||
export class SearchResultGroup {
|
||||
libraries: Array<Library> = [];
|
||||
series: Array<SearchResult> = [];
|
||||
collections: Array<Tag> = [];
|
||||
readingLists: Array<Tag> = [];
|
||||
persons: Array<Tag> = [];
|
||||
genres: Array<Tag> = [];
|
||||
tags: Array<Tag> = [];
|
||||
|
||||
reset() {
|
||||
this.libraries = [];
|
||||
this.series = [];
|
||||
this.collections = [];
|
||||
this.readingLists = [];
|
||||
this.persons = [];
|
||||
this.genres = [];
|
||||
this.tags = [];
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ export interface SeriesFilter {
|
|||
readStatus: ReadStatus;
|
||||
genres: Array<number>;
|
||||
writers: Array<number>;
|
||||
artists: Array<number>;
|
||||
penciller: Array<number>;
|
||||
inker: Array<number>;
|
||||
colorist: Array<number>;
|
||||
|
@ -68,4 +69,10 @@ export const mangaFormatFilters = [
|
|||
value: MangaFormat.ARCHIVE,
|
||||
selected: false
|
||||
}
|
||||
];
|
||||
];
|
||||
|
||||
export interface FilterEvent {
|
||||
filter: SeriesFilter;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
|
|
14
UI/Web/src/app/_models/series-group.ts
Normal file
14
UI/Web/src/app/_models/series-group.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { LibraryType } from "./library";
|
||||
|
||||
export interface SeriesGroup {
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
created: string;
|
||||
title: string;
|
||||
libraryId: number;
|
||||
libraryType: LibraryType;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
id: number; // This is UI only, sent from backend but has no relation to any entity
|
||||
count: number;
|
||||
}
|
|
@ -4,6 +4,7 @@ import { Preferences } from './preferences/preferences';
|
|||
export interface User {
|
||||
username: string;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
roles: string[];
|
||||
preferences: Preferences;
|
||||
apiKey: string;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { Observable, ReplaySubject, Subject } from 'rxjs';
|
||||
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Preferences } from '../_models/preferences/preferences';
|
||||
|
@ -22,6 +22,11 @@ export class AccountService implements OnDestroy {
|
|||
private currentUserSource = new ReplaySubject<User>(1);
|
||||
currentUser$ = this.currentUserSource.asObservable();
|
||||
|
||||
/**
|
||||
* SetTimeout handler for keeping track of refresh token call
|
||||
*/
|
||||
private refreshTokenTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router,
|
||||
|
@ -36,6 +41,10 @@ export class AccountService implements OnDestroy {
|
|||
return user && user.roles.includes('Admin');
|
||||
}
|
||||
|
||||
hasChangePasswordRole(user: User) {
|
||||
return user && user.roles.includes('Change Password');
|
||||
}
|
||||
|
||||
hasDownloadRole(user: User) {
|
||||
return user && user.roles.includes('Download');
|
||||
}
|
||||
|
@ -44,7 +53,7 @@ export class AccountService implements OnDestroy {
|
|||
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
|
||||
}
|
||||
|
||||
login(model: any): Observable<any> {
|
||||
login(model: {username: string, password: string}): Observable<any> {
|
||||
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
|
||||
map((response: User) => {
|
||||
const user = response;
|
||||
|
@ -69,22 +78,30 @@ export class AccountService implements OnDestroy {
|
|||
|
||||
this.currentUserSource.next(user);
|
||||
this.currentUser = user;
|
||||
if (this.currentUser !== undefined) {
|
||||
this.startRefreshTokenTimer();
|
||||
} else {
|
||||
this.stopRefreshTokenTimer();
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.userKey);
|
||||
this.currentUserSource.next(undefined);
|
||||
this.currentUser = undefined;
|
||||
this.stopRefreshTokenTimer();
|
||||
// Upon logout, perform redirection
|
||||
this.router.navigateByUrl('/login');
|
||||
this.messageHub.stopHubConnection();
|
||||
}
|
||||
|
||||
register(model: {username: string, password: string, isAdmin?: boolean}) {
|
||||
if (!model.hasOwnProperty('isAdmin')) {
|
||||
model.isAdmin = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the first admin on the account. Only used for that. All other registrations must occur through invite
|
||||
* @param model
|
||||
* @returns
|
||||
*/
|
||||
register(model: {username: string, password: string, email: string}) {
|
||||
return this.httpClient.post<User>(this.baseUrl + 'account/register', model).pipe(
|
||||
map((user: User) => {
|
||||
return user;
|
||||
|
@ -93,14 +110,46 @@ export class AccountService implements OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
confirmMigrationEmail(model: {email: string, token: string}) {
|
||||
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-migration-email', model);
|
||||
}
|
||||
|
||||
resendConfirmationEmail(userId: number) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, sendEmail: boolean}) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
confirmEmail(model: {email: string, username: string, password: string, token: string}) {
|
||||
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email', model);
|
||||
}
|
||||
|
||||
getDecodedToken(token: string) {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
}
|
||||
|
||||
requestResetPasswordEmail(email: string) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
confirmResetPasswordEmail(model: {email: string, token: string, password: string}) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model);
|
||||
}
|
||||
|
||||
resetPassword(username: string, password: string) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password}, {responseType: 'json' as 'text'});
|
||||
}
|
||||
|
||||
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number}) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update', model);
|
||||
}
|
||||
|
||||
updatePreferences(userPreferences: Preferences) {
|
||||
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
|
||||
if (this.currentUser !== undefined || this.currentUser != null) {
|
||||
|
@ -135,8 +184,45 @@ export class AccountService implements OnDestroy {
|
|||
}
|
||||
return key;
|
||||
}));
|
||||
|
||||
|
||||
}
|
||||
|
||||
private refreshToken() {
|
||||
if (this.currentUser === null || this.currentUser === undefined) return of();
|
||||
|
||||
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
|
||||
if (this.currentUser) {
|
||||
this.currentUser.token = user.token;
|
||||
this.currentUser.refreshToken = user.refreshToken;
|
||||
}
|
||||
|
||||
this.currentUserSource.next(this.currentUser);
|
||||
this.startRefreshTokenTimer();
|
||||
return user;
|
||||
}));
|
||||
}
|
||||
|
||||
private startRefreshTokenTimer() {
|
||||
if (this.currentUser === null || this.currentUser === undefined) return;
|
||||
|
||||
if (this.refreshTokenTimeout !== undefined) {
|
||||
this.stopRefreshTokenTimer();
|
||||
}
|
||||
|
||||
const jwtToken = JSON.parse(atob(this.currentUser.token.split('.')[1]));
|
||||
// set a timeout to refresh the token a minute before it expires
|
||||
const expires = new Date(jwtToken.exp * 1000);
|
||||
const timeout = expires.getTime() - Date.now() - (60 * 1000);
|
||||
this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => {
|
||||
console.log('Token Refreshed');
|
||||
}), timeout);
|
||||
}
|
||||
|
||||
private stopRefreshTokenTimer() {
|
||||
if (this.refreshTokenTimeout !== undefined) {
|
||||
clearTimeout(this.refreshTokenTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -149,7 +149,7 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
|
||||
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Refresh started for ' + series.name);
|
||||
this.toastr.success('Refresh covers queued for ' + series.name);
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -214,7 +214,7 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
|
||||
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
|
||||
chapter.pagesRead = 0;
|
||||
this.toastr.success('Marked as unread');
|
||||
if (callback) {
|
||||
|
@ -375,7 +375,7 @@ export class ActionService implements OnDestroy {
|
|||
*/
|
||||
addMultipleSeriesToCollectionTag(series: Array<Series>, callback?: VoidActionCallback) {
|
||||
if (this.collectionModalRef != null) { return; }
|
||||
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md' });
|
||||
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection' });
|
||||
this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id);
|
||||
this.collectionModalRef.componentInstance.title = 'New Collection';
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
|
|||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||
import { AccountService } from './account.service';
|
||||
import { NavService } from './nav.service';
|
||||
|
||||
|
@ -41,6 +42,25 @@ export class ImageService implements OnDestroy {
|
|||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
getRecentlyAddedItem(item: RecentlyAddedItem) {
|
||||
if (item.chapterId === 0) {
|
||||
return this.getVolumeCoverImage(item.volumeId);
|
||||
}
|
||||
return this.getChapterCoverImage(item.chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity type from a cover image url. Undefied if not applicable
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
getEntityTypeFromUrl(url: string) {
|
||||
if (url.indexOf('?') < 0) return undefined;
|
||||
const part = url.split('?')[1];
|
||||
const equalIndex = part.indexOf('=');
|
||||
return part.substring(0, equalIndex).replace('Id', '');
|
||||
}
|
||||
|
||||
getVolumeCoverImage(volumeId: number) {
|
||||
return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId;
|
||||
}
|
||||
|
@ -71,7 +91,7 @@ export class ImageService implements OnDestroy {
|
|||
* @returns Url with a random parameter attached
|
||||
*/
|
||||
randomize(url: string) {
|
||||
const r = Math.random() * 100 + 1;
|
||||
const r = Math.round(Math.random() * 100 + 1);
|
||||
if (url.indexOf('&random') >= 0) {
|
||||
return url + 1;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { map, take } from 'rxjs/operators';
|
|||
import { environment } from 'src/environments/environment';
|
||||
import { Library, LibraryType } from '../_models/library';
|
||||
import { SearchResult } from '../_models/search-result';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
|
||||
|
||||
@Injectable({
|
||||
|
@ -34,6 +35,21 @@ export class LibraryService {
|
|||
}));
|
||||
}
|
||||
|
||||
getLibraryName(libraryId: number) {
|
||||
if (this.libraryNames != undefined && this.libraryNames.hasOwnProperty(libraryId)) {
|
||||
return of(this.libraryNames[libraryId]);
|
||||
}
|
||||
return this.httpClient.get<Library[]>(this.baseUrl + 'library').pipe(map(l => {
|
||||
this.libraryNames = {};
|
||||
l.forEach(lib => {
|
||||
if (this.libraryNames !== undefined) {
|
||||
this.libraryNames[lib.id] = lib.name;
|
||||
}
|
||||
});
|
||||
return this.libraryNames[libraryId];
|
||||
}));
|
||||
}
|
||||
|
||||
listDirectories(rootPath: string) {
|
||||
let query = '';
|
||||
if (rootPath !== undefined && rootPath.length > 0) {
|
||||
|
@ -91,9 +107,9 @@ export class LibraryService {
|
|||
|
||||
search(term: string) {
|
||||
if (term === '') {
|
||||
return of([]);
|
||||
return of(new SearchResultGroup());
|
||||
}
|
||||
return this.httpClient.get<SearchResult[]>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
|
||||
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export class MemberService {
|
|||
}
|
||||
|
||||
deleteMember(username: string) {
|
||||
return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + username);
|
||||
return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + encodeURIComponent(username));
|
||||
}
|
||||
|
||||
hasLibraryAccess(libraryId: number) {
|
||||
|
@ -36,7 +36,8 @@ export class MemberService {
|
|||
return this.httpClient.get<boolean>(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId);
|
||||
}
|
||||
|
||||
updateMemberRoles(username: string, roles: string[]) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update-rbs', {username, roles});
|
||||
|
||||
getPendingInvites() {
|
||||
return this.httpClient.get<Array<Member>>(this.baseUrl + 'users/pending');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ import { User } from '../_models/user';
|
|||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable',
|
||||
ScanSeries = 'ScanSeries',
|
||||
RefreshMetadata = 'RefreshMetadata',
|
||||
RefreshMetadataProgress = 'RefreshMetadataProgress',
|
||||
SeriesAdded = 'SeriesAdded',
|
||||
SeriesRemoved = 'SeriesRemoved',
|
||||
|
@ -25,7 +24,11 @@ export enum EVENTS {
|
|||
ScanLibraryError = 'ScanLibraryError',
|
||||
BackupDatabaseProgress = 'BackupDatabaseProgress',
|
||||
CleanupProgress = 'CleanupProgress',
|
||||
DownloadProgress = 'DownloadProgress'
|
||||
DownloadProgress = 'DownloadProgress',
|
||||
/**
|
||||
* A cover is updated
|
||||
*/
|
||||
CoverUpdate = 'CoverUpdate'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
|
@ -49,7 +52,6 @@ export class MessageHubService {
|
|||
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
|
||||
public scanLibrary: EventEmitter<ProgressEvent> = new EventEmitter<ProgressEvent>(); // TODO: Refactor this name to be generic
|
||||
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
|
||||
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
|
||||
|
||||
isAdmin: boolean = false;
|
||||
|
||||
|
@ -143,10 +145,6 @@ export class MessageHubService {
|
|||
payload: resp.body
|
||||
});
|
||||
this.seriesAdded.emit(resp.body);
|
||||
// Don't show the toast when user has reader open
|
||||
if (this.isAdmin && this.router.url.match(/\d+\/manga|book\/\d+/gi) !== null) {
|
||||
this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added');
|
||||
}
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SeriesRemoved, resp => {
|
||||
|
@ -156,12 +154,19 @@ export class MessageHubService {
|
|||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.RefreshMetadata, resp => {
|
||||
// this.hubConnection.on(EVENTS.RefreshMetadata, resp => {
|
||||
// this.messagesSource.next({
|
||||
// event: EVENTS.RefreshMetadata,
|
||||
// payload: resp.body
|
||||
// });
|
||||
// this.refreshMetadata.emit(resp.body); // TODO: Remove this
|
||||
// });
|
||||
|
||||
this.hubConnection.on(EVENTS.CoverUpdate, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.RefreshMetadata,
|
||||
event: EVENTS.CoverUpdate,
|
||||
payload: resp.body
|
||||
});
|
||||
this.refreshMetadata.emit(resp.body);
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.UpdateAvailable, resp => {
|
||||
|
|
|
@ -42,7 +42,7 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<AgeRatingDto>>(this.baseUrl + method);;
|
||||
return this.httpClient.get<Array<AgeRatingDto>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllPublicationStatus(libraries?: Array<number>) {
|
||||
|
@ -50,7 +50,7 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<PublicationStatusDto>>(this.baseUrl + method);;
|
||||
return this.httpClient.get<Array<PublicationStatusDto>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllTags(libraries?: Array<number>) {
|
||||
|
@ -58,7 +58,7 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<Tag>>(this.baseUrl + method);;
|
||||
return this.httpClient.get<Array<Tag>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllGenres(libraries?: Array<number>) {
|
||||
|
@ -66,7 +66,7 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Genre[]>(this.baseUrl + method);
|
||||
return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllLanguages(libraries?: Array<number>) {
|
||||
|
@ -74,7 +74,7 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Language[]>(this.baseUrl + method);
|
||||
return this.httpClient.get<Array<Language>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllPeople(libraries?: Array<number>) {
|
||||
|
@ -82,6 +82,6 @@ export class MetadataService {
|
|||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Person[]>(this.baseUrl + method);
|
||||
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,33 +103,12 @@ export class ReaderService {
|
|||
return this.httpClient.get<number>(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '¤tChapterId=' + currentChapterId);
|
||||
}
|
||||
|
||||
getCurrentChapter(volumes: Array<Volume>): Chapter {
|
||||
let currentlyReadingChapter: Chapter | undefined = undefined;
|
||||
const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
|
||||
hasSeriesProgress(seriesId: number) {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'reader/has-progress?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
for (const c of chapters) {
|
||||
if (c.pagesRead < c.pages) {
|
||||
currentlyReadingChapter = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentlyReadingChapter === undefined) {
|
||||
// Check if there are specials we can load:
|
||||
const specials = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
|
||||
for (const c of specials) {
|
||||
if (c.pagesRead < c.pages) {
|
||||
currentlyReadingChapter = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentlyReadingChapter === undefined) {
|
||||
// Default to first chapter
|
||||
currentlyReadingChapter = chapters[0];
|
||||
}
|
||||
}
|
||||
|
||||
return currentlyReadingChapter;
|
||||
getCurrentChapter(seriesId: number) {
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,10 +5,11 @@ import { map } from 'rxjs/operators';
|
|||
import { environment } from 'src/environments/environment';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { InProgressChapter } from '../_models/in-progress-chapter';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||
import { Series } from '../_models/series';
|
||||
import { ReadStatus, SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesGroup } from '../_models/series-group';
|
||||
import { SeriesMetadata } from '../_models/series-metadata';
|
||||
import { Volume } from '../_models/volume';
|
||||
import { ImageService } from './image.service';
|
||||
|
@ -123,6 +124,13 @@ 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);
|
||||
|
||||
|
@ -135,9 +143,6 @@ export class SeriesService {
|
|||
}));
|
||||
}
|
||||
|
||||
getContinueReading(libraryId: number = 0) {
|
||||
return this.httpClient.get<InProgressChapter[]>(this.baseUrl + 'series/continue-reading?libraryId=' + libraryId);
|
||||
}
|
||||
|
||||
refreshMetadata(series: Series) {
|
||||
return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id});
|
||||
|
@ -193,6 +198,7 @@ export class SeriesService {
|
|||
libraries: [],
|
||||
genres: [],
|
||||
writers: [],
|
||||
artists: [],
|
||||
penciller: [],
|
||||
inker: [],
|
||||
colorist: [],
|
||||
|
|
|
@ -36,4 +36,8 @@ export class ServerService {
|
|||
getChangelog() {
|
||||
return this.httpClient.get<UpdateVersionEvent[]>(this.baseUrl + 'server/changelog', {});
|
||||
}
|
||||
|
||||
isServerAccessible() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'server/accessible');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
|
||||
loadChildren(path: string) {
|
||||
this.libraryService.listDirectories(path).subscribe(folders => {
|
||||
this.filterQuery = '';
|
||||
this.folders = folders;
|
||||
}, err => {
|
||||
// If there was an error, pop off last directory added to stack
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{member?.username}}'s Roles</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{role.data}}" class="form-check-input"
|
||||
[(ngModel)]="role.selected" name="library">
|
||||
<label attr.for="library-{{i}}" class="form-check-label">{{role.data}}</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
|
@ -21,7 +21,6 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||
isLoading: boolean = false;
|
||||
|
||||
get hasSomeSelected() {
|
||||
console.log(this.selections != null && this.selections.hasSomeSelected());
|
||||
return this.selections != null && this.selections.hasSomeSelected();
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ export interface ServerSettings {
|
|||
port: number;
|
||||
allowStatCollection: boolean;
|
||||
enableOpds: boolean;
|
||||
enableAuthentication: boolean;
|
||||
baseUrl: string;
|
||||
bookmarksDirectory: string;
|
||||
emailServiceUrl: string;
|
||||
}
|
||||
|
|
|
@ -12,10 +12,13 @@ import { DirectoryPickerComponent } from './_modals/directory-picker/directory-p
|
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ManageSettingsComponent } from './manage-settings/manage-settings.component';
|
||||
import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component';
|
||||
import { ManageSystemComponent } from './manage-system/manage-system.component';
|
||||
import { ChangelogComponent } from './changelog/changelog.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { InviteUserComponent } from './invite-user/invite-user.component';
|
||||
import { RoleSelectorComponent } from './role-selector/role-selector.component';
|
||||
import { LibrarySelectorComponent } from './library-selector/library-selector.component';
|
||||
import { EditUserComponent } from './edit-user/edit-user.component';
|
||||
|
||||
|
||||
|
||||
|
@ -30,9 +33,12 @@ import { PipeModule } from '../pipe/pipe.module';
|
|||
DirectoryPickerComponent,
|
||||
ResetPasswordModalComponent,
|
||||
ManageSettingsComponent,
|
||||
EditRbsModalComponent,
|
||||
ManageSystemComponent,
|
||||
ChangelogComponent,
|
||||
InviteUserComponent,
|
||||
RoleSelectorComponent,
|
||||
LibrarySelectorComponent,
|
||||
EditUserComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
|
58
UI/Web/src/app/admin/edit-user/edit-user.component.html
Normal file
58
UI/Web/src/app/admin/edit-user/edit-user.component.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<div class="row no-gutters">
|
||||
<div class="col-md-6 col-sm-12 pr-2">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="form-group" style="width:100%">
|
||||
<label for="email">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" [disabled]="true">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col-md-6">
|
||||
<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()">
|
||||
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 ? 'Saving...' : 'Update'}}</span>
|
||||
</button>
|
||||
</div>
|
62
UI/Web/src/app/admin/edit-user/edit-user.component.ts
Normal file
62
UI/Web/src/app/admin/edit-user/edit-user.component.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
|
||||
// TODO: Rename this to EditUserModal
|
||||
@Component({
|
||||
selector: 'app-edit-user',
|
||||
templateUrl: './edit-user.component.html',
|
||||
styleUrls: ['./edit-user.component.scss']
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
|
||||
@Input() member!: Member;
|
||||
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
isSaving: boolean = false;
|
||||
|
||||
userForm: FormGroup = new FormGroup({});
|
||||
|
||||
public get email() { return this.userForm.get('email'); }
|
||||
public get username() { return this.userForm.get('username'); }
|
||||
public get password() { return this.userForm.get('password'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
|
||||
private confirmService: ConfirmService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email]));
|
||||
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required]));
|
||||
|
||||
this.userForm.get('email')?.disable();
|
||||
}
|
||||
|
||||
updateRoleSelection(roles: Array<string>) {
|
||||
this.selectedRoles = roles;
|
||||
}
|
||||
|
||||
updateLibrarySelection(libraries: Array<Library>) {
|
||||
this.selectedLibraries = libraries.map(l => l.id);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(false);
|
||||
}
|
||||
|
||||
save() {
|
||||
const model = this.userForm.getRawValue();
|
||||
model.userId = this.member.id;
|
||||
model.roles = this.selectedRoles;
|
||||
model.libraries = this.selectedLibraries;
|
||||
this.accountService.update(model).subscribe(() => {
|
||||
this.modal.close(true);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
56
UI/Web/src/app/admin/invite-user/invite-user.component.html
Normal file
56
UI/Web/src/app/admin/invite-user/invite-user.component.html
Normal file
|
@ -0,0 +1,56 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Invite a user to your server. Enter their email in and we will send them an email to create an account.
|
||||
</p>
|
||||
|
||||
<p *ngIf="!checkedAccessibility">
|
||||
<span class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" aria-hidden="true"></span>
|
||||
Checking accessibility of server...
|
||||
</p>
|
||||
|
||||
|
||||
<form [formGroup]="inviteForm">
|
||||
<div class="row no-gutters">
|
||||
<div class="form-group" style="width:100%">
|
||||
<label for="email">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required>
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="emailLink !== '' && checkedAccessibility && !accessible">
|
||||
<p>Use this link to finish setting up the user account due to your server not being accessible outside your local network.</p>
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank">{{emailLink}}</a>
|
||||
</ng-container>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || !checkedAccessibility || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
.email-link {
|
||||
word-break: break-all;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
81
UI/Web/src/app/admin/invite-user/invite-user.component.ts
Normal file
81
UI/Web/src/app/admin/invite-user/invite-user.component.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite-user',
|
||||
templateUrl: './invite-user.component.html',
|
||||
styleUrls: ['./invite-user.component.scss']
|
||||
})
|
||||
export class InviteUserComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Maintains if the backend is sending an email
|
||||
*/
|
||||
isSending: boolean = false;
|
||||
inviteForm: FormGroup = new FormGroup({});
|
||||
/**
|
||||
* If a user would be able to load this server up externally
|
||||
*/
|
||||
accessible: boolean = true;
|
||||
checkedAccessibility: boolean = false;
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
emailLink: string = '';
|
||||
|
||||
public get email() { return this.inviteForm.get('email'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
|
||||
private confirmService: ConfirmService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
|
||||
|
||||
this.serverService.isServerAccessible().subscribe(async (accessibile) => {
|
||||
if (!accessibile) {
|
||||
await this.confirmService.alert('This server is not accessible outside the network. You cannot invite via Email. You wil be given a link to finish registration with instead.');
|
||||
this.accessible = accessibile;
|
||||
}
|
||||
this.checkedAccessibility = true;
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(false);
|
||||
}
|
||||
|
||||
invite() {
|
||||
|
||||
this.isSending = true;
|
||||
const email = this.inviteForm.get('email')?.value;
|
||||
this.accountService.inviteUser({
|
||||
email,
|
||||
libraries: this.selectedLibraries,
|
||||
roles: this.selectedRoles,
|
||||
sendEmail: this.accessible
|
||||
}).subscribe(emailLink => {
|
||||
this.emailLink = emailLink;
|
||||
this.isSending = false;
|
||||
if (this.accessible) {
|
||||
this.toastr.info('Email sent to ' + email);
|
||||
this.modal.close(true);
|
||||
}
|
||||
}, err => {
|
||||
this.isSending = false;
|
||||
});
|
||||
}
|
||||
|
||||
updateRoleSelection(roles: Array<string>) {
|
||||
this.selectedRoles = roles;
|
||||
}
|
||||
|
||||
updateLibrarySelection(libraries: Array<Library>) {
|
||||
this.selectedLibraries = libraries.map(l => l.id);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
<h4>Libraries</h4>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<div class="form-check" *ngIf="allLibraries.length > 0">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
There are no libraries setup yet.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
.list-group-item {
|
||||
border: none;
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-selector',
|
||||
templateUrl: './library-selector.component.html',
|
||||
styleUrls: ['./library-selector.component.scss']
|
||||
})
|
||||
export class LibrarySelectorComponent implements OnInit {
|
||||
|
||||
@Input() member: Member | undefined;
|
||||
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
|
||||
|
||||
allLibraries: Library[] = [];
|
||||
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
||||
selections!: SelectionModel<Library>;
|
||||
selectAll: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
|
||||
get hasSomeSelected() {
|
||||
return this.selections != null && this.selections.hasSomeSelected();
|
||||
}
|
||||
|
||||
constructor(private libraryService: LibraryService, private fb: FormBuilder) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.libraryService.getLibraries().subscribe(libs => {
|
||||
this.allLibraries = libs;
|
||||
this.setupSelections();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setupSelections() {
|
||||
this.selections = new SelectionModel<Library>(false, this.allLibraries);
|
||||
this.isLoading = false;
|
||||
|
||||
// If a member is passed in, then auto-select their libraries
|
||||
if (this.member !== undefined) {
|
||||
this.member.libraries.forEach(lib => {
|
||||
this.selections.toggle(lib, true, (a, b) => a.name === b.name);
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
this.selected.emit(this.selections.selected());
|
||||
}
|
||||
}
|
||||
|
||||
toggleAll() {
|
||||
this.selectAll = !this.selectAll;
|
||||
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
|
||||
this.selected.emit(this.selections.selected());
|
||||
}
|
||||
|
||||
handleSelection(item: Library) {
|
||||
this.selections.toggle(item);
|
||||
const numberOfSelected = this.selections.selected().length;
|
||||
if (numberOfSelected == 0) {
|
||||
this.selectAll = false;
|
||||
} else if (numberOfSelected == this.selectedLibraries.length) {
|
||||
this.selectAll = true;
|
||||
}
|
||||
|
||||
this.selected.emit(this.selections.selected());
|
||||
}
|
||||
|
||||
}
|
|
@ -64,16 +64,30 @@
|
|||
<label for="opds" class="form-check-label">Enable OPDS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h4>Email Services (SMTP)</h4>
|
||||
<p class="accent">Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
|
||||
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always
|
||||
be saved to logs.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="authentication" aria-describedby="authentication-info">Authentication</label>
|
||||
<p class="accent" id="authentication-info">By disabling authentication, all non-admin users will be able to login by just their username. No password will be required to authenticate.</p>
|
||||
<div class="form-check">
|
||||
<input id="authentication" type="checkbox" aria-label="User Authentication" class="form-check-input" formControlName="enableAuthentication">
|
||||
<label for="authentication" class="form-check-label">Enable Authentication</label>
|
||||
<label for="settings-emailservice">Email Service Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #emailServiceTooltip>Use fully qualified url of the email service. Do not include ending slash.</ng-template>
|
||||
<span class="sr-only" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" (click)="resetEmailServiceUrl()">
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="testEmailServiceUrl()">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h4>Reoccuring Tasks</h4>
|
||||
<div class="form-group">
|
||||
<label for="settings-tasks-scan">Library Scan</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
|
||||
|
|
|
@ -4,10 +4,11 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { EmailTestResult, SettingsService } from '../settings.service';
|
||||
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-settings',
|
||||
templateUrl: './manage-settings.component.html',
|
||||
|
@ -40,8 +41,8 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
|
||||
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
|
||||
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
|
||||
this.settingsForm.addControl('enableAuthentication', new FormControl(this.serverSettings.enableAuthentication, [Validators.required]));
|
||||
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required]));
|
||||
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -54,29 +55,17 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
|
||||
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
|
||||
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
|
||||
this.settingsForm.get('enableAuthentication')?.setValue(this.serverSettings.enableAuthentication);
|
||||
this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl);
|
||||
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const modelSettings = this.settingsForm.value;
|
||||
|
||||
if (this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value === false) {
|
||||
if (!await this.confirmService.confirm('Disabling Authentication opens your server up to unauthorized access and possible hacking. Are you sure you want to continue with this?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const informUserAfterAuthenticationEnabled = this.settingsForm.get('enableAuthentication')?.dirty && this.settingsForm.get('enableAuthentication')?.value && !this.serverSettings.enableAuthentication;
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
|
||||
if (informUserAfterAuthenticationEnabled) {
|
||||
await this.confirmService.alert('You have just re-enabled authentication. All non-admin users have been re-assigned a password of "[k.2@RZ!mxCQkJzE". This is a publicly known password. Please change their users passwords or request them to.');
|
||||
}
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -104,4 +93,28 @@ export class ManageSettingsComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
resetEmailServiceUrl() {
|
||||
this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
|
||||
this.resetForm();
|
||||
this.toastr.success('Email Service Reset');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
testEmailServiceUrl() {
|
||||
this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => {
|
||||
if (result.successful) {
|
||||
this.toastr.success('Email Service Url validated');
|
||||
} else {
|
||||
this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
|
||||
}
|
||||
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,19 +1,53 @@
|
|||
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-8"><h3>Users</h3></div>
|
||||
<div class="col-4"><button class="btn btn-primary float-right" (click)="createMember()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden"> Add User</span></button></div>
|
||||
</div>
|
||||
<ul class="list-group" *ngIf="!createMemberToggle; else createUser">
|
||||
<ng-container>
|
||||
<div class="row mb-2">
|
||||
<div class="col-8"><h3>Pending Invites</h3></div>
|
||||
<div class="col-4"><button class="btn btn-primary float-right" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden"> Invite</span></button></div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" *ngFor="let invite of pendingInvites; let idx = index;">
|
||||
<div>
|
||||
<h4>
|
||||
<span id="member-name--{{idx}}">{{invite.username | titlecase}} </span>
|
||||
<div class="float-right">
|
||||
<button class="btn btn-danger mr-2" (click)="deleteUser(invite)">Cancel</button>
|
||||
<button class="btn btn-secondary mr-2" (click)="resendEmail(invite)">Resend</button>
|
||||
</div>
|
||||
</h4>
|
||||
|
||||
<div>Invited: {{invite.created | date: 'short'}}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loadingMembers" class="list-group-item">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="pendingInvites.length === 0 && !loadingMembers">
|
||||
There are no invited Users
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
||||
<h3 class="mt-3">Active Users</h3>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let member of members; let idx = index;" class="list-group-item">
|
||||
<div>
|
||||
<h4>
|
||||
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i><span id="member-name--{{idx}}">{{member.username | titlecase}} </span><span *ngIf="member.username === loggedInUsername">(You)</span>
|
||||
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
|
||||
<span id="member-name--{{idx}}">{{member.username | titlecase}} </span>
|
||||
<span *ngIf="member.username === loggedInUsername">
|
||||
<i class="fas fa-star" aria-hidden="true"></i>
|
||||
<span class="sr-only">(You)</span>
|
||||
</span>
|
||||
<div class="float-right" *ngIf="canEditMember(member)">
|
||||
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-primary" (click)="openEditLibraryAccess(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-primary" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</h4>
|
||||
<div>Last Active:
|
||||
|
@ -22,16 +56,12 @@
|
|||
{{member.lastActive | date: 'short'}}
|
||||
</ng-template>
|
||||
</div>
|
||||
<div *ngIf="!member.isAdmin">Sharing: {{formatLibraries(member)}}</div>
|
||||
<div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div>
|
||||
<div>
|
||||
Roles: <span *ngIf="getRoles(member).length === 0; else showRoles">None</span>
|
||||
<ng-template #showRoles>
|
||||
<app-tag-badge *ngFor="let role of getRoles(member)">{{role}}</app-tag-badge>
|
||||
</ng-template>
|
||||
<button class="btn btn-icon" attr.aria-labelledby="member-name--{{idx}}" title="{{hasAdminRole(member) ? 'Admins have all feature permissions' : 'Edit Role'}}" (click)="openEditRole(member)" [disabled]="hasAdminRole(member)">
|
||||
<i class="fa fa-pen" aria-hidden="true"></i>
|
||||
<span class="sr-only">Edit Role</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -44,7 +74,4 @@
|
|||
There are no other users.
|
||||
</li>
|
||||
</ul>
|
||||
<ng-template #createUser>
|
||||
<app-register-member (created)="onMemberCreated($event)"></app-register-member>
|
||||
</ng-template>
|
||||
</div>
|
|
@ -5,13 +5,14 @@ import { MemberService } from 'src/app/_services/member.service';
|
|||
import { Member } from 'src/app/_models/member';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { LibraryAccessModalComponent } from '../_modals/library-access-modal/library-access-modal.component';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component';
|
||||
import { Subject } from 'rxjs';
|
||||
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { InviteUserComponent } from '../invite-user/invite-user.component';
|
||||
import { EditUserComponent } from '../edit-user/edit-user.component';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
|
@ -21,10 +22,8 @@ import { MessageHubService } from 'src/app/_services/message-hub.service';
|
|||
export class ManageUsersComponent implements OnInit, OnDestroy {
|
||||
|
||||
members: Member[] = [];
|
||||
pendingInvites: Member[] = [];
|
||||
loggedInUsername = '';
|
||||
|
||||
// Create User functionality
|
||||
createMemberToggle = false;
|
||||
loadingMembers = false;
|
||||
|
||||
private onDestroy = new Subject<void>();
|
||||
|
@ -34,7 +33,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
private modalService: NgbModal,
|
||||
private toastr: ToastrService,
|
||||
private confirmService: ConfirmService,
|
||||
public messageHub: MessageHubService) {
|
||||
public messageHub: MessageHubService,
|
||||
private serverService: ServerService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
|
||||
this.loggedInUsername = user.username;
|
||||
});
|
||||
|
@ -43,6 +43,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
|
||||
this.loadPendingInvites();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
@ -69,44 +71,69 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
loadPendingInvites() {
|
||||
this.memberService.getPendingInvites().subscribe(members => {
|
||||
this.pendingInvites = members;
|
||||
// Show logged in user at the top of the list
|
||||
this.pendingInvites.sort((a: Member, b: Member) => {
|
||||
if (a.username === this.loggedInUsername) return 1;
|
||||
if (b.username === this.loggedInUsername) return 1;
|
||||
|
||||
const nameA = a.username.toUpperCase();
|
||||
const nameB = b.username.toUpperCase();
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
return 0;
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
canEditMember(member: Member): boolean {
|
||||
return this.loggedInUsername !== member.username;
|
||||
}
|
||||
|
||||
createMember() {
|
||||
this.createMemberToggle = true;
|
||||
}
|
||||
|
||||
onMemberCreated(createdUser: User | null) {
|
||||
this.createMemberToggle = false;
|
||||
this.loadMembers();
|
||||
}
|
||||
|
||||
openEditLibraryAccess(member: Member) {
|
||||
const modalRef = this.modalService.open(LibraryAccessModalComponent);
|
||||
openEditUser(member: Member) {
|
||||
const modalRef = this.modalService.open(EditUserComponent, {size: 'lg'});
|
||||
modalRef.componentInstance.member = member;
|
||||
modalRef.closed.subscribe(() => {
|
||||
this.loadMembers();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async deleteUser(member: Member) {
|
||||
if (await this.confirmService.confirm('Are you sure you want to delete this user?')) {
|
||||
this.memberService.deleteMember(member.username).subscribe(() => {
|
||||
this.loadMembers();
|
||||
this.loadPendingInvites();
|
||||
this.toastr.success(member.username + ' has been deleted.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openEditRole(member: Member) {
|
||||
const modalRef = this.modalService.open(EditRbsModalComponent);
|
||||
modalRef.componentInstance.member = member;
|
||||
modalRef.closed.subscribe((updatedMember: Member) => {
|
||||
if (updatedMember !== undefined) {
|
||||
member = updatedMember;
|
||||
inviteUser() {
|
||||
const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'});
|
||||
modalRef.closed.subscribe((successful: boolean) => {
|
||||
if (successful) {
|
||||
this.loadPendingInvites();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
resendEmail(member: Member) {
|
||||
|
||||
this.serverService.isServerAccessible().subscribe(canAccess => {
|
||||
this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => {
|
||||
if (canAccess) {
|
||||
this.toastr.info('Email sent to ' + member.username);
|
||||
return;
|
||||
}
|
||||
await this.confirmService.alert(
|
||||
'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking. <br/> <a href="' + email + '" target="_blank">' + email + '</a>');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
updatePassword(member: Member) {
|
||||
|
@ -129,4 +156,5 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
getRoles(member: Member) {
|
||||
return member.roles.filter(item => item != 'Pleb');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
<h4>Roles</h4>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="role-{{i}}" type="checkbox" attr.aria-label="Role {{role.data}}" class="form-check-input"
|
||||
[(ngModel)]="role.selected" name="role" (ngModelChange)="handleModelUpdate()">
|
||||
<label attr.for="role-{{i}}" class="form-check-label">{{role.data}}</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
|
@ -0,0 +1,3 @@
|
|||
.list-group-item {
|
||||
border: none;
|
||||
}
|
|
@ -1,17 +1,23 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-rbs-modal',
|
||||
templateUrl: './edit-rbs-modal.component.html',
|
||||
styleUrls: ['./edit-rbs-modal.component.scss']
|
||||
selector: 'app-role-selector',
|
||||
templateUrl: './role-selector.component.html',
|
||||
styleUrls: ['./role-selector.component.scss']
|
||||
})
|
||||
export class EditRbsModalComponent implements OnInit {
|
||||
export class RoleSelectorComponent implements OnInit {
|
||||
|
||||
@Input() member: Member | undefined;
|
||||
/**
|
||||
* Allows the selection of Admin role
|
||||
*/
|
||||
@Input() allowAdmin: boolean = false;
|
||||
@Output() selected: EventEmitter<string[]> = new EventEmitter<string[]>();
|
||||
|
||||
allRoles: string[] = [];
|
||||
selectedRoles: Array<{selected: boolean, data: string}> = [];
|
||||
|
||||
|
@ -19,45 +25,20 @@ export class EditRbsModalComponent implements OnInit {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.accountService.getRoles().subscribe(roles => {
|
||||
roles = roles.filter(item => item != 'Admin' && item != 'Pleb'); // Do not allow the user to modify Account RBS
|
||||
let bannedRoles = ['Pleb'];
|
||||
if (!this.allowAdmin) {
|
||||
bannedRoles.push('Admin');
|
||||
}
|
||||
roles = roles.filter(item => !bannedRoles.includes(item));
|
||||
this.allRoles = roles;
|
||||
this.selectedRoles = roles.map(item => {
|
||||
return {selected: false, data: item};
|
||||
});
|
||||
|
||||
this.preselect();
|
||||
this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(undefined);
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.member?.username === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRoles = this.selectedRoles.filter(item => item.selected).map(item => item.data);
|
||||
this.memberService.updateMemberRoles(this.member?.username, selectedRoles).subscribe(() => {
|
||||
if (this.member) {
|
||||
this.member.roles = selectedRoles;
|
||||
this.modal.close(this.member);
|
||||
return;
|
||||
}
|
||||
this.modal.close(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.selectedRoles = this.allRoles.map(item => {
|
||||
return {selected: false, data: item};
|
||||
});
|
||||
|
||||
|
||||
this.preselect();
|
||||
}
|
||||
|
||||
preselect() {
|
||||
if (this.member !== undefined) {
|
||||
this.member.roles.forEach(role => {
|
||||
|
@ -69,4 +50,8 @@ export class EditRbsModalComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
handleModelUpdate() {
|
||||
this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data));
|
||||
}
|
||||
|
||||
}
|
|
@ -1,9 +1,16 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ServerSettings } from './_models/server-settings';
|
||||
|
||||
/**
|
||||
* Used only for the Test Email Service call
|
||||
*/
|
||||
export interface EmailTestResult {
|
||||
successful: boolean;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
|
@ -25,6 +32,14 @@ export class SettingsService {
|
|||
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset', {});
|
||||
}
|
||||
|
||||
resetEmailServerSettings() {
|
||||
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset-email-url', {});
|
||||
}
|
||||
|
||||
testEmailServerSettings(emailUrl: string) {
|
||||
return this.http.post<EmailTestResult>(this.baseUrl + 'settings/test-email-url', {url: emailUrl});
|
||||
}
|
||||
|
||||
getTaskFrequencies() {
|
||||
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
|
||||
}
|
||||
|
@ -40,10 +55,4 @@ export class SettingsService {
|
|||
getOpdsEnabled() {
|
||||
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
getAuthenticationEnabled() {
|
||||
return this.http.get<string>(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'}).pipe(map((res: string) => {
|
||||
return res === 'true';
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { FilterEvent, SeriesFilter } from '../_models/series-filter';
|
||||
import { ActionItem, Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { MessageHubService } from '../_services/message-hub.service';
|
||||
|
@ -70,14 +70,15 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
|||
|
||||
constructor(private router: Router, private seriesService: SeriesService,
|
||||
private titleService: Title, private actionService: ActionService,
|
||||
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
|
||||
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService, private route: ActivatedRoute) {
|
||||
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.titleService.setTitle('Kavita - All Series');
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
|
||||
this.loadPage();
|
||||
|
||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter());
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -105,9 +106,9 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
updateFilter(data: FilterEvent) {
|
||||
this.filter = data.filter;
|
||||
if (this.pagination !== undefined && this.pagination !== null && !data.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
|
@ -116,11 +117,10 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
loadPage() {
|
||||
const page = this.getPage();
|
||||
if (page != null) {
|
||||
this.pagination.currentPage = parseInt(page, 10);
|
||||
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
|
||||
if (this.filter == undefined) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
}
|
||||
this.loadingSeries = true;
|
||||
|
||||
this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
|
|
|
@ -65,7 +65,11 @@ const routes: Routes = [
|
|||
|
||||
]
|
||||
},
|
||||
{path: 'login', component: UserLoginComponent},
|
||||
{
|
||||
path: 'registration',
|
||||
loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)
|
||||
},
|
||||
{path: 'login', component: UserLoginComponent}, // TODO: move this to registration module
|
||||
{path: 'no-connection', component: NotConnectedComponent},
|
||||
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
|
||||
];
|
||||
|
|
|
@ -40,7 +40,6 @@ export class AppComponent implements OnInit {
|
|||
|
||||
setCurrentUser() {
|
||||
const user = this.accountService.getUserFromLocalStorage();
|
||||
|
||||
this.accountService.setCurrentUser(user);
|
||||
|
||||
if (user) {
|
||||
|
|
|
@ -18,7 +18,6 @@ import { SharedModule } from './shared/shared.module';
|
|||
import { LibraryDetailComponent } from './library-detail/library-detail.component';
|
||||
import { SeriesDetailComponent } from './series-detail/series-detail.component';
|
||||
import { NotConnectedComponent } from './not-connected/not-connected.component';
|
||||
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
|
||||
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
|
||||
import { CarouselModule } from './carousel/carousel.module';
|
||||
|
||||
|
@ -36,6 +35,8 @@ import { PersonRolePipe } from './person-role.pipe';
|
|||
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { PublicationStatusPipe } from './publication-status.pipe';
|
||||
import { RegistrationModule } from './registration/registration.module';
|
||||
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
|
@ -56,6 +57,7 @@ import { PublicationStatusPipe } from './publication-status.pipe';
|
|||
PublicationStatusPipe,
|
||||
SeriesMetadataDetailComponent,
|
||||
AllSeriesComponent,
|
||||
GroupedTypeaheadComponent,
|
||||
],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
|
@ -66,7 +68,6 @@ import { PublicationStatusPipe } from './publication-status.pipe';
|
|||
FormsModule, // EditCollection Modal
|
||||
|
||||
NgbDropdownModule, // Nav
|
||||
AutocompleteLibModule, // Nav
|
||||
NgbPopoverModule, // Nav Events toggle
|
||||
NgbRatingModule, // Series Detail
|
||||
NgbNavModule,
|
||||
|
@ -80,6 +81,7 @@ import { PublicationStatusPipe } from './publication-status.pipe';
|
|||
CardsModule,
|
||||
CollectionsModule,
|
||||
ReadingListModule,
|
||||
RegistrationModule,
|
||||
|
||||
ToastrModule.forRoot({
|
||||
positionClass: 'toast-bottom-right',
|
||||
|
|
|
@ -117,33 +117,40 @@
|
|||
[innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate" tabindex="-1"></div>
|
||||
<div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate" tabindex="-1"></div>
|
||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate" tabindex="-1"></div>
|
||||
|
||||
<div *ngIf="page !== undefined && scrollbarNeeded">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div *ngIf="page !== undefined && scrollbarNeeded">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
</div> -->
|
||||
|
||||
<ng-template #actionBar>
|
||||
<div class="reading-bar row no-gutters justify-content-between">
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()"
|
||||
[disabled]="IsPrevDisabled"
|
||||
title="{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} Page">
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? pageNum === 0 : pageNum + 1 >= maxPages - 1) ? 'fa-angle-double-left' : 'fa-angle-left'}}" aria-hidden="true"></i>
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}}" aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{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="phone-hidden"> 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="phone-hidden"> Settings</span></button>
|
||||
<div class="book-title col-2 phone-hidden">{{bookTitle}} <span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode</span>)</span></div>
|
||||
<div class="book-title col-2 phone-hidden">
|
||||
<ng-container *ngIf="isLoading; else showTitle">
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
||||
<span class="sr-only">Loading book...</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #showTitle>
|
||||
{{bookTitle}}
|
||||
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode</span>)</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="phone-hidden"> Close</span></button>
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||
[disabled]="IsNextDisabled"
|
||||
(click)="nextPage()" title="{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} Page">
|
||||
<span class="phone-hidden">{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} </span>
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? pageNum + 1 > maxPages - 1 : pageNum === 0) ? 'fa-angle-double-right' : 'fa-angle-right'}}" aria-hidden="true"></i>
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
|
|
@ -202,9 +202,22 @@ $primary-color: #0062cc;
|
|||
|
||||
.right {
|
||||
position: fixed;
|
||||
right: 0px;
|
||||
right: 0px; // with scrollbar: 17px
|
||||
top: 0px;
|
||||
width: 20%;
|
||||
width: 20%; // with scrollbar: 18%
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// This class pushes the click area to the left a bit to let users click the scrollbar
|
||||
.right-with-scrollbar {
|
||||
position: fixed;
|
||||
right: 17px;
|
||||
top: 0px;
|
||||
width: 18%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
|
|
|
@ -43,6 +43,15 @@ const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up
|
|||
const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
|
||||
/**
|
||||
* Styles that should be applied on the top level book-content tag
|
||||
*/
|
||||
const pageLevelStyles = ['margin-left', 'margin-right', 'font-size'];
|
||||
/**
|
||||
* Styles that should be applied on every element within book-content tag
|
||||
*/
|
||||
const elementLevelStyles = ['line-height', 'font-family'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-reader',
|
||||
templateUrl: './book-reader.component.html',
|
||||
|
@ -235,6 +244,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
get IsNextChapter(): boolean {
|
||||
return this.pageNum + 1 >= this.maxPages;
|
||||
}
|
||||
get IsPrevChapter(): boolean {
|
||||
return this.pageNum === 0;
|
||||
}
|
||||
|
||||
get drawerBackgroundColor() {
|
||||
return this.darkMode ? '#010409': '#fff';
|
||||
}
|
||||
|
@ -344,12 +360,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.lastSeenScrollPartPath = path;
|
||||
}
|
||||
|
||||
if (this.lastSeenScrollPartPath !== '' && !this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
if (this.lastSeenScrollPartPath !== '') {
|
||||
this.saveProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveProgress() {
|
||||
let tempPageNum = this.pageNum;
|
||||
if (this.pageNum == this.maxPages - 1) {
|
||||
tempPageNum = this.pageNum + 1;
|
||||
}
|
||||
|
||||
if (!this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
const bodyNode = this.document.querySelector('body');
|
||||
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
|
||||
|
@ -450,9 +478,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
if (this.pageNum >= this.maxPages) {
|
||||
this.pageNum = this.maxPages - 1;
|
||||
if (!this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
this.saveProgress();
|
||||
}
|
||||
|
||||
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||
|
@ -494,6 +520,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
event.preventDefault();
|
||||
} else if (event.key === KEY_CODES.G) {
|
||||
this.goToPage();
|
||||
} else if (event.key === KEY_CODES.F) {
|
||||
this.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -587,7 +615,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
let margin = '15%';
|
||||
if (windowWidth <= 700) {
|
||||
margin = '0%';
|
||||
margin = '5%';
|
||||
}
|
||||
if (this.user) {
|
||||
if (windowWidth > 700) {
|
||||
|
@ -702,9 +730,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
loadPage(part?: string | undefined, scrollTop?: number | undefined) {
|
||||
this.isLoading = true;
|
||||
|
||||
if (!this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
this.saveProgress();
|
||||
|
||||
this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => {
|
||||
this.page = this.domSanitizer.bypassSecurityTrustHtml(content);
|
||||
|
@ -755,8 +781,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
setPageNum(pageNum: number) {
|
||||
if (pageNum < 0) {
|
||||
this.pageNum = 0;
|
||||
} else if (pageNum >= this.maxPages) {
|
||||
this.pageNum = this.maxPages - 1;
|
||||
} else if (pageNum >= this.maxPages - 1) { // This case handles when we are using the pager to move to the next volume/chapter, the pageNum will get incremented past maxPages // NOTE: I made a change where I removed - 1 in comparison, it's breaking page progress
|
||||
this.pageNum = this.maxPages; //
|
||||
} else {
|
||||
this.pageNum = pageNum;
|
||||
}
|
||||
|
@ -785,6 +811,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
prevPage() {
|
||||
const oldPageNum = this.pageNum;
|
||||
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
this.setPageNum(this.pageNum - 1);
|
||||
} else {
|
||||
|
@ -807,18 +834,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const oldPageNum = this.pageNum;
|
||||
if (oldPageNum + 1 === this.maxPages) {
|
||||
// Move to next volume/chapter automatically
|
||||
this.loadNextChapter();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
this.setPageNum(this.pageNum + 1);
|
||||
} else {
|
||||
this.setPageNum(this.pageNum - 1);
|
||||
}
|
||||
|
||||
if (oldPageNum + 1 === this.maxPages) {
|
||||
// Move to next volume/chapter automatically
|
||||
this.loadNextChapter();
|
||||
}
|
||||
|
||||
|
||||
if (oldPageNum === this.pageNum) { return; }
|
||||
|
||||
|
@ -875,31 +905,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.updateReaderStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles onto the html of the book page
|
||||
*/
|
||||
updateReaderStyles() {
|
||||
if (this.readingHtml != undefined && this.readingHtml.nativeElement) {
|
||||
Object.entries(this.pageStyles).forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]);
|
||||
return;
|
||||
}
|
||||
this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important);
|
||||
});
|
||||
if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return;
|
||||
|
||||
for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) {
|
||||
const elem = this.readingHtml.nativeElement.children.item(i);
|
||||
if (elem?.tagName === 'STYLE') continue;
|
||||
Object.entries(this.pageStyles).forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(elem, item[0]);
|
||||
return;
|
||||
}
|
||||
this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important);
|
||||
});
|
||||
|
||||
// Line Height must be placed on each element in the page
|
||||
|
||||
// Apply page level overrides
|
||||
Object.entries(this.pageStyles).forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]);
|
||||
return;
|
||||
}
|
||||
if (pageLevelStyles.includes(item[0])) {
|
||||
this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important);
|
||||
}
|
||||
});
|
||||
|
||||
const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0]));
|
||||
for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) {
|
||||
const elem = this.readingHtml.nativeElement.children.item(i);
|
||||
if (elem?.tagName === 'STYLE') continue;
|
||||
individualElementStyles.forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(elem, item[0]);
|
||||
return;
|
||||
}
|
||||
this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -1042,7 +1082,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.saveProgress();
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
|
|
|
@ -4,4 +4,17 @@
|
|||
|
||||
.clickable:hover, .clickable:focus {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
}
|
||||
|
||||
.collection {
|
||||
overflow: auto;
|
||||
.modal-body {
|
||||
height: calc(100vh - 235px);
|
||||
min-height: 150px;
|
||||
.list-group {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 355px);
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
|
||||
import { Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, FormControl } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
@ -9,6 +9,7 @@ import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
|||
@Component({
|
||||
selector: 'app-bulk-add-to-collection',
|
||||
templateUrl: './bulk-add-to-collection.component.html',
|
||||
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
|
||||
styleUrls: ['./bulk-add-to-collection.component.scss']
|
||||
})
|
||||
export class BulkAddToCollectionComponent implements OnInit {
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<ul class="list-unstyled">
|
||||
<li class="media my-4" *ngFor="let chapter of chapters">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<img class="mr-3" style="width: 74px" [src]="chapter.coverImage">
|
||||
<app-image class="mr-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
|
|
|
@ -104,7 +104,7 @@
|
|||
</div>
|
||||
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
|
||||
<li class="media my-4" *ngFor="let volume of seriesVolumes">
|
||||
<img class="mr-3" style="width: 74px;" src="{{imageService.getVolumeCoverImage(volume.id)}}" >
|
||||
<app-image class="mr-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
|
||||
<div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
|||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
|
@ -120,13 +120,15 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
this.settings.id = 'collections';
|
||||
this.settings.unique = true;
|
||||
this.settings.addIfNonExisting = true;
|
||||
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter);
|
||||
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.settings.compareFn(items, filter)));
|
||||
this.settings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
|
||||
});
|
||||
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.settings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<div class="card" *ngIf="bookmark != undefined">
|
||||
<img class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="170px">
|
||||
<app-image height="230px" width="170px" [imageUrl]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"></app-image>
|
||||
|
||||
<div class="card-body" *ngIf="bookmark.page >= 0">
|
||||
<div class="header-row">
|
||||
|
|
|
@ -5,16 +5,16 @@
|
|||
<span *ngIf="actions.length > 0" class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span>{{header}}
|
||||
<!-- NOTE: On mobile the pill can eat up a lot of space, we can hide it and move to the filter section if user is interested -->
|
||||
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
|
||||
<button *ngIf="!filteringDisabled" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="sr-only">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Language } from 'src/app/_models/metadata/language';
|
|||
import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Person, PersonRole } from 'src/app/_models/person';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||
import { Tag } from 'src/app/_models/tag';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
|
@ -57,6 +57,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
@Input() pagination!: Pagination;
|
||||
/**
|
||||
* Should filtering be shown on the page
|
||||
*/
|
||||
@Input() filteringDisabled: boolean = false;
|
||||
/**
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
|
@ -65,7 +69,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
@Input() filterSettings!: FilterSettings;
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<SeriesFilter> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
|
@ -95,6 +99,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
|
||||
updateApplied: number = 0;
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
|
@ -194,10 +199,13 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.formatSettings.id = 'format';
|
||||
this.formatSettings.unique = true;
|
||||
this.formatSettings.addIfNonExisting = false;
|
||||
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters);
|
||||
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter)));
|
||||
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.formatSettings.singleCompareFn = (a: FilterItem<MangaFormat>, b: FilterItem<MangaFormat>) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) {
|
||||
|
@ -214,11 +222,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.librarySettings.unique = true;
|
||||
this.librarySettings.addIfNonExisting = false;
|
||||
this.librarySettings.fetchFn = (filter: string) => {
|
||||
return this.libraryService.getLibrariesForMember();
|
||||
return this.libraryService.getLibrariesForMember()
|
||||
.pipe(map(items => this.librarySettings.compareFn(items, filter)));
|
||||
};
|
||||
this.librarySettings.compareFn = (options: Library[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.name.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
this.librarySettings.singleCompareFn = (a: Library, b: Library) => {
|
||||
return a.name == b.name;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) {
|
||||
|
@ -238,11 +249,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.genreSettings.unique = true;
|
||||
this.genreSettings.addIfNonExisting = false;
|
||||
this.genreSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllGenres(this.filter.libraries);
|
||||
return this.metadataService.getAllGenres(this.filter.libraries)
|
||||
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
|
||||
};
|
||||
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) {
|
||||
|
@ -261,12 +275,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.ageRatingSettings.id = 'age-rating';
|
||||
this.ageRatingSettings.unique = true;
|
||||
this.ageRatingSettings.addIfNonExisting = false;
|
||||
this.ageRatingSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllAgeRatings(this.filter.libraries);
|
||||
};
|
||||
this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries)
|
||||
.pipe(map(items => this.ageRatingSettings.compareFn(items, filter)));
|
||||
|
||||
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.ageRatingSettings.singleCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) {
|
||||
|
@ -285,12 +302,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.publicationStatusSettings.id = 'publication-status';
|
||||
this.publicationStatusSettings.unique = true;
|
||||
this.publicationStatusSettings.addIfNonExisting = false;
|
||||
this.publicationStatusSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllPublicationStatus(this.filter.libraries);
|
||||
};
|
||||
this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries)
|
||||
.pipe(map(items => this.publicationStatusSettings.compareFn(items, filter)));
|
||||
|
||||
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
this.publicationStatusSettings.singleCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) {
|
||||
|
@ -309,12 +329,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.tagsSettings.id = 'tags';
|
||||
this.tagsSettings.unique = true;
|
||||
this.tagsSettings.addIfNonExisting = false;
|
||||
this.tagsSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllTags(this.filter.libraries);
|
||||
};
|
||||
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries)
|
||||
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
|
||||
|
||||
this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) {
|
||||
|
@ -333,12 +355,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.languageSettings.id = 'languages';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllLanguages(this.filter.libraries);
|
||||
};
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.singleCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) {
|
||||
|
@ -357,12 +381,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
this.collectionSettings.id = 'collections';
|
||||
this.collectionSettings.unique = true;
|
||||
this.collectionSettings.addIfNonExisting = false;
|
||||
this.collectionSettings.fetchFn = (filter: string) => {
|
||||
return this.collectionTagService.allTags();
|
||||
};
|
||||
this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags()
|
||||
.pipe(map(items => this.collectionSettings.compareFn(items, filter)));
|
||||
|
||||
this.collectionSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) {
|
||||
|
@ -427,11 +453,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
personSettings.addIfNonExisting = false;
|
||||
personSettings.id = id;
|
||||
personSettings.compareFn = (options: Person[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.name.toLowerCase() === f);
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
|
||||
personSettings.singleCompareFn = (a: Person, b: Person) => {
|
||||
return a.name == b.name && a.role == b.role;
|
||||
}
|
||||
personSettings.fetchFn = (filter: string) => {
|
||||
return this.fetchPeople(role, filter);
|
||||
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
|
||||
};
|
||||
return personSettings;
|
||||
}
|
||||
|
@ -566,7 +595,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
apply() {
|
||||
this.applyFilter.emit(this.filter);
|
||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
<div class="card {{selected ? 'selected-highlight' : ''}}">
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
<img *ngIf="total > 0 || supressArchiveWarning" class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
|
||||
<img *ngIf="total === 0 && !supressArchiveWarning" class="img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
|
||||
aria-hidden="true" height="230px" width="158px">
|
||||
<ng-container *ngIf="total > 0 || supressArchiveWarning">
|
||||
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageUrl"></app-image>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="total === 0 && !supressArchiveWarning">
|
||||
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
|
||||
</ng-container>
|
||||
|
||||
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
|
@ -22,6 +25,10 @@
|
|||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
|
||||
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
|
||||
<div class="count" *ngIf="count > 1">
|
||||
<span class="badge badge-primary">{{count}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
|
@ -38,6 +45,7 @@
|
|||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
</span>
|
||||
</div>
|
||||
<span class="card-title library" [ngbTooltip]="subtitle" placement="top" *ngIf="subtitle.length > 0">{{subtitle}}</span>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!supressLibraryLink && libraryName">{{libraryName | sentenceCase}}</a>
|
||||
</div>
|
||||
</div>
|
|
@ -118,6 +118,12 @@ $image-width: 160px;
|
|||
}
|
||||
|
||||
z-index: 10;
|
||||
|
||||
.count {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Chapter } from 'src/app/_models/chapter';
|
|||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { RecentlyAddedItem } from 'src/app/_models/recently-added-item';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
@ -31,6 +32,10 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
* Name of the card
|
||||
*/
|
||||
@Input() title = '';
|
||||
/**
|
||||
* Shows below the title. Defaults to not visible
|
||||
*/
|
||||
@Input() subtitle = '';
|
||||
/**
|
||||
* Any actions to perform on the card
|
||||
*/
|
||||
|
@ -50,7 +55,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
/**
|
||||
* This is the entity we are representing. It will be returned if an action is executed.
|
||||
*/
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark;
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
|
@ -59,6 +64,14 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
* If the entity should show selection code
|
||||
*/
|
||||
@Input() allowSelection: boolean = false;
|
||||
/**
|
||||
* This will supress the cannot read archive warning when total pages is 0
|
||||
*/
|
||||
@Input() supressArchiveWarning: boolean = false;
|
||||
/**
|
||||
* The number of updates/items within the card. If less than 2, will not be shown.
|
||||
*/
|
||||
@Input() count: number = 0;
|
||||
/**
|
||||
* Event emitted when item is clicked
|
||||
*/
|
||||
|
@ -72,10 +85,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
*/
|
||||
libraryName: string | undefined = undefined;
|
||||
libraryId: number | undefined = undefined;
|
||||
/**
|
||||
* This will supress the cannot read archive warning when total pages is 0
|
||||
*/
|
||||
supressArchiveWarning: boolean = false;
|
||||
/**
|
||||
* Format of the entity (only applies to Series)
|
||||
*/
|
||||
|
@ -110,12 +119,15 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
if (this.supressLibraryLink === false) {
|
||||
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
||||
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
|
||||
this.libraryId = (this.entity as Series).libraryId;
|
||||
this.libraryName = names[this.libraryId];
|
||||
}
|
||||
});
|
||||
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
|
||||
this.libraryId = (this.entity as Series).libraryId;
|
||||
}
|
||||
|
||||
if (this.libraryId !== undefined && this.libraryId > 0) {
|
||||
this.libraryService.getLibraryName(this.libraryId).pipe(takeUntil(this.onDestroy)).subscribe(name => {
|
||||
this.libraryName = name;
|
||||
});
|
||||
}
|
||||
}
|
||||
this.format = (this.entity as Series).format;
|
||||
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
|
||||
|
||||
<!-- Arc Information -->
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Id: {{chapter.id}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
|
@ -28,7 +32,7 @@
|
|||
<ul class="list-unstyled" >
|
||||
<li class="media my-4">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<img class="mr-3" style="width: 74px" [src]="chapter.coverImage">
|
||||
<app-image class="mr-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
|
|
|
@ -53,10 +53,10 @@
|
|||
|
||||
<div class="row no-gutters chooser" style="padding-top: 10px">
|
||||
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
|
||||
<img class="card-img-top" [src]="url" aria-hidden="true" height="230px" width="158px" (error)="imageService.updateErroredImage($event)">
|
||||
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
|
||||
</div>
|
||||
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
|
||||
<img class="card-img-top" title="Reset Cover Image" [src]="imageService.resetCoverImage" aria-hidden="true" height="230px" width="158px">
|
||||
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -61,13 +61,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
ngOnInit(): void {
|
||||
if (this.data) {
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
|
||||
this.hubService.refreshMetadata.pipe(takeWhile(event => event.libraryId === this.libraryId), takeUntil(this.onDestroy)).subscribe((event: RefreshMetadataEvent) => {
|
||||
if (this.data.id === event.seriesId) {
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
}
|
||||
});
|
||||
this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<div class="container-fluid" *ngIf="collectionTag !== undefined" style="padding-top: 10px">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6">
|
||||
<img class="poster lazyload" [src]="imageService.placeholderImage" [attr.data-src]="tagImage"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true">
|
||||
<app-image class="poster" maxWidth="481px" [imageUrl]="tagImage"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6">
|
||||
<div class="row no-gutters">
|
||||
<h2>
|
||||
|
||||
{{collectionTag.title}}
|
||||
</h2>
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@ import { CollectionTag } from 'src/app/_models/collection-tag';
|
|||
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { FilterEvent, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
|
@ -175,9 +175,9 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.seriesPagination !== undefined && this.seriesPagination !== null) {
|
||||
updateFilter(data: FilterEvent) {
|
||||
this.filter = data.filter;
|
||||
if (this.seriesPagination !== undefined && this.seriesPagination !== null && !data.isFirst) {
|
||||
this.seriesPagination.currentPage = 1;
|
||||
this.onPageChange(this.seriesPagination);
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
|
||||
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
|
||||
<div>
|
||||
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
|
||||
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
|
||||
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)"
|
||||
>
|
||||
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<button type="button" class="close" aria-label="Close" (click)="resetField()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown" *ngIf="hasFocus">
|
||||
<ul class="list-group" role="listbox" id="dropdown">
|
||||
<ng-container *ngIf="seriesTemplate !== undefined && grouppedData.series.length > 0">
|
||||
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
|
||||
<ul class="list-group results" role="group" aria-describedby="series-group">
|
||||
<li *ngFor="let option of grouppedData.series; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" aria-labelledby="series-group" role="option">
|
||||
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="collectionTemplate !== undefined && grouppedData.collections.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Collections</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.collections; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="readingListTemplate !== undefined && grouppedData.readingLists.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Reading Lists</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.readingLists; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="libraryTemplate !== undefined && grouppedData.libraries.length > 0">
|
||||
<li class="list-group-item section-header"><h5 id="libraries-group">Libraries</h5></li>
|
||||
<ul class="list-group results" role="group" aria-describedby="libraries-group">
|
||||
<li *ngFor="let option of grouppedData.libraries; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" aria-labelledby="libraries-group" role="option">
|
||||
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="genreTemplate !== undefined && grouppedData.genres.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Genres</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.genres; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="tagTemplate !== undefined && grouppedData.tags.length > 0">
|
||||
<li class="list-group-item section-header"><h5>Tags</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.tags; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="personTemplate !== undefined && grouppedData.persons.length > 0">
|
||||
<li class="list-group-item section-header"><h5>People</h5></li>
|
||||
<ul class="list-group results">
|
||||
<li *ngFor="let option of grouppedData.persons; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
||||
class="list-group-item" role="option">
|
||||
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="noResultsTemplate != undefined && searchTerm.length > 0 && !grouppedData.persons.length && !grouppedData.collections.length && !grouppedData.series.length && !grouppedData.persons.length && !grouppedData.tags.length && !grouppedData.genres.length">
|
||||
<ul class="list-group results">
|
||||
<li class="list-group-item">
|
||||
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</form>
|
|
@ -0,0 +1,198 @@
|
|||
@use "../../theme/colors";
|
||||
form {
|
||||
max-height: 38px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 15px;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
left: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.search-result img {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
|
||||
.typeahead-input {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0px 6px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
box-shadow: none;
|
||||
cursor: text;
|
||||
background-color: #fff;
|
||||
min-height: 38px;
|
||||
transition-property: all;
|
||||
transition-duration: 0.3s;
|
||||
display: block;
|
||||
|
||||
|
||||
.close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width:650px) {
|
||||
.close {
|
||||
top: 50%;
|
||||
transform: translate(0, -60%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input {
|
||||
outline: 0 !important;
|
||||
border-radius: .28571429rem;
|
||||
display: inline-block !important;
|
||||
padding: 0px !important;
|
||||
min-height: 0px !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0px !important;
|
||||
text-indent: 0 !important;
|
||||
line-height: inherit !important;
|
||||
box-shadow: none !important;
|
||||
width: 300px;
|
||||
transition-property: all;
|
||||
transition-duration: 0.3s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
width: calc(100vw - 400px);
|
||||
}
|
||||
|
||||
input:empty {
|
||||
padding-top: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.typeahead-input.focused {
|
||||
width: 100%;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
/* small devices (phones, 650px and down) */
|
||||
@media only screen and (max-width:650px) {
|
||||
.typeahead-input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .bg-dark .typeahead-input {
|
||||
color: #efefef;
|
||||
background-color: colors.$dark-bg-color;
|
||||
}
|
||||
|
||||
// Causes bleedover
|
||||
::ng-deep .bg-dark .dropdown .list-group-item.hover {
|
||||
background-color: colors.$dark-hover-color;
|
||||
}
|
||||
|
||||
|
||||
.dropdown {
|
||||
width: 100vw;
|
||||
height: calc(100vh - 57px); //header offset
|
||||
background: rgba(0,0,0,0.5);
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
max-width: 600px;
|
||||
z-index:1000;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: block;
|
||||
flex: auto;
|
||||
max-height: calc(100vh - 58px);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.list-group.results {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.list-group {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
border-radius: 0px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
border-radius: 0px !important;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::ng-deep .bg-dark {
|
||||
& .section-header {
|
||||
|
||||
background: colors.$dark-item-accent-bg;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
& .section-header:hover {
|
||||
background-color: colors.$dark-item-accent-bg !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .bg-light {
|
||||
& .section-header {
|
||||
|
||||
background: colors.$white-item-accent-bg;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
& .section-header:hover, .list-group-item.section-header:hover {
|
||||
background: colors.$white-item-accent-bg !important;
|
||||
}
|
||||
|
||||
& .list-group-item:hover {
|
||||
background-color: colors.$primary-color !important;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
top: 30%;
|
||||
}
|
181
UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts
Normal file
181
UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
|
||||
@Component({
|
||||
selector: 'app-grouped-typeahead',
|
||||
templateUrl: './grouped-typeahead.component.html',
|
||||
styleUrls: ['./grouped-typeahead.component.scss']
|
||||
})
|
||||
export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Unique id to tie with a label element
|
||||
*/
|
||||
@Input() id: string = 'grouped-typeahead';
|
||||
/**
|
||||
* Minimum number of characters in input to trigger a search
|
||||
*/
|
||||
@Input() minQueryLength: number = 0;
|
||||
/**
|
||||
* Initial value of the search model
|
||||
*/
|
||||
@Input() initialValue: string = '';
|
||||
@Input() grouppedData: SearchResultGroup = new SearchResultGroup();
|
||||
/**
|
||||
* Placeholder for the input
|
||||
*/
|
||||
@Input() placeholder: string = '';
|
||||
/**
|
||||
* Number of milliseconds after typing before triggering inputChanged for data fetching
|
||||
*/
|
||||
@Input() debounceTime: number = 200;
|
||||
/**
|
||||
* Emits when the input changes from user interaction
|
||||
*/
|
||||
@Output() inputChanged: EventEmitter<string> = new EventEmitter();
|
||||
/**
|
||||
* Emits when something is clicked/selected
|
||||
*/
|
||||
@Output() selected: EventEmitter<any> = new EventEmitter();
|
||||
/**
|
||||
* Emits an event when the field is cleared
|
||||
*/
|
||||
@Output() clearField: EventEmitter<void> = new EventEmitter();
|
||||
/**
|
||||
* Emits when a change in the search field looses/gains focus
|
||||
*/
|
||||
@Output() focusChanged: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
|
||||
@ContentChild('itemTemplate') itemTemplate!: TemplateRef<any>;
|
||||
@ContentChild('seriesTemplate') seriesTemplate: TemplateRef<any> | undefined;
|
||||
@ContentChild('collectionTemplate') collectionTemplate: TemplateRef<any> | undefined;
|
||||
@ContentChild('tagTemplate') tagTemplate: TemplateRef<any> | undefined;
|
||||
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
|
||||
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
|
||||
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
|
||||
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
|
||||
|
||||
|
||||
hasFocus: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
typeaheadForm: FormGroup = new FormGroup({});
|
||||
|
||||
prevSearchTerm: string = '';
|
||||
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
get searchTerm() {
|
||||
return this.typeaheadForm.get('typeahead')?.value || '';
|
||||
}
|
||||
|
||||
get hasData() {
|
||||
return this.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length;
|
||||
}
|
||||
|
||||
|
||||
constructor() { }
|
||||
|
||||
@HostListener('window:click', ['$event'])
|
||||
handleDocumentClick(event: any) {
|
||||
this.close();
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (!this.hasFocus) { return; }
|
||||
|
||||
switch(event.key) {
|
||||
case KEY_CODES.ESC_KEY:
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
||||
|
||||
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntil(this.onDestroy)).subscribe(change => {
|
||||
const value = this.typeaheadForm.get('typeahead')?.value;
|
||||
|
||||
if (value != undefined && value != '' && !this.hasFocus) {
|
||||
this.hasFocus = true;
|
||||
}
|
||||
|
||||
if (value != undefined && value.length >= this.minQueryLength) {
|
||||
|
||||
if (this.prevSearchTerm === value) return;
|
||||
this.inputChanged.emit(value);
|
||||
this.prevSearchTerm = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
onInputFocus(event: any) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.openDropdown();
|
||||
return this.hasFocus;
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
setTimeout(() => {
|
||||
const model = this.typeaheadForm.get('typeahead');
|
||||
if (model) {
|
||||
model.setValue(model.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleResultlick(item: any) {
|
||||
this.selected.emit(item);
|
||||
}
|
||||
|
||||
resetField() {
|
||||
this.prevSearchTerm = '';
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
this.clearField.emit();
|
||||
}
|
||||
|
||||
|
||||
close(event?: FocusEvent) {
|
||||
if (event) {
|
||||
// If the user is tabbing out of the input field, check if there are results first before closing
|
||||
if (this.hasData) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.searchTerm === '') {
|
||||
this.resetField();
|
||||
}
|
||||
this.hasFocus = false;
|
||||
this.focusChanged.emit(this.hasFocus);
|
||||
}
|
||||
|
||||
open(event?: FocusEvent) {
|
||||
this.hasFocus = true;
|
||||
this.focusChanged.emit(this.hasFocus);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.prevSearchTerm = '';
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
}
|
||||
|
||||
}
|
|
@ -10,7 +10,7 @@ import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
|||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { FilterEvent, SeriesFilter } from '../_models/series-filter';
|
||||
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
|
@ -138,9 +138,10 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
updateFilter(event: FilterEvent) {
|
||||
this.filter = event.filter;
|
||||
const page = this.getPage();
|
||||
if (page === undefined || page === null || !event.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
|
|
|
@ -11,9 +11,24 @@
|
|||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<app-carousel-reel [items]="recentlyAdded" title="Recently Added" (sectionClick)="handleSectionClick($event)">
|
||||
<!-- TODO: Refactor this so we can use series actions here -->
|
||||
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="loadRecentlyAdded()"></app-series-card>
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<app-carousel-reel [items]="recentlyAddedSeries" title="Recently Added Series" (sectionClick)="handleSectionClick($event)">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||
import { InProgressChapter } from '../_models/in-progress-chapter';
|
||||
import { Library } from '../_models/library';
|
||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesGroup } from '../_models/series-group';
|
||||
import { User } from '../_models/user';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { ImageService } from '../_services/image.service';
|
||||
|
@ -27,13 +29,17 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||
isLoading = false;
|
||||
isAdmin = false;
|
||||
|
||||
recentlyAdded: Series[] = [];
|
||||
recentlyUpdatedSeries: SeriesGroup[] = [];
|
||||
recentlyAddedChapters: RecentlyAddedItem[] = [];
|
||||
inProgress: Series[] = [];
|
||||
continueReading: InProgressChapter[] = [];
|
||||
recentlyAddedSeries: Series[] = [];
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`;
|
||||
/**
|
||||
* We use this Replay subject to slow the amount of times we reload the UI
|
||||
*/
|
||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
private seriesService: SeriesService, private router: Router,
|
||||
|
@ -42,15 +48,24 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
|
||||
if (res.event === EVENTS.SeriesAdded) {
|
||||
const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
||||
|
||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||
this.recentlyAdded.unshift(series);
|
||||
this.recentlyAddedSeries.unshift(series);
|
||||
});
|
||||
} else if (res.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
|
||||
this.recentlyAdded = this.recentlyAdded.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntil(this.onDestroy)).subscribe(() => this.loadRecentlyAdded());
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -74,8 +89,9 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
reloadSeries() {
|
||||
this.loadRecentlyAdded();
|
||||
this.loadOnDeck();
|
||||
this.loadRecentlyAdded();
|
||||
this.loadRecentlyAddedSeries();
|
||||
}
|
||||
|
||||
reloadInProgress(series: Series | boolean) {
|
||||
|
@ -97,12 +113,27 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
loadRecentlyAdded() {
|
||||
this.seriesService.getRecentlyAdded(0, 0, 20).pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => {
|
||||
this.recentlyAdded = updatedSeries.result;
|
||||
loadRecentlyAddedSeries() {
|
||||
this.seriesService.getRecentlyAdded().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
|
||||
this.recentlyAddedSeries = updatedSeries.result;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
loadRecentlyAdded() {
|
||||
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) {
|
||||
this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
|
||||
}
|
||||
|
||||
handleSectionClick(sectionTitle: string) {
|
||||
if (sectionTitle.toLowerCase() === 'collections') {
|
||||
this.router.navigate(['collections']);
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
|
||||
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
|
||||
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
|
||||
<strong>Pages:</strong> {{pageNum}} / {{totalPages}}
|
||||
<strong>Pages:</strong> {{pageNum}} / {{totalPages - 1}}
|
||||
<strong>At Top:</strong> {{atTop}}
|
||||
<strong>At Bottom:</strong> {{atBottom}}
|
||||
<strong>Total Height:</strong> {{getTotalHeight()}}
|
||||
|
@ -27,7 +27,7 @@
|
|||
</div>
|
||||
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
|
||||
<img src="{{item.src}}" style="display: block"
|
||||
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}"
|
||||
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}} {{initFinished ? '' : 'full-opacity'}}"
|
||||
*ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image"
|
||||
(load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
||||
</ng-container>
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
border: 2px solid red;
|
||||
}
|
||||
|
||||
.full-opacity {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
|
|
|
@ -61,7 +61,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
@Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>();
|
||||
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
@Input() goToPage: BehaviorSubject<number> | undefined;
|
||||
@Input() bookmarkPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
@Input() fullscreenToggled: ReplaySubject<boolean> = new ReplaySubject<boolean>();
|
||||
|
||||
|
@ -121,10 +121,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block
|
||||
*/
|
||||
previousScrollHeightMinusTop: number = 0;
|
||||
/**
|
||||
* Tracks the first load, until all the initial prefetched images are loaded. We use this to reduce opacity so images can load without jerk.
|
||||
*/
|
||||
initFinished: boolean = false;
|
||||
/**
|
||||
* Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output
|
||||
*/
|
||||
debugMode: DEBUG_MODES = DEBUG_MODES.None;
|
||||
/**
|
||||
* Debug mode. Will filter out any messages in here so they don't hit the log
|
||||
*/
|
||||
debugLogFilter: Array<string> = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]'];
|
||||
|
||||
get minPageLoaded() {
|
||||
return Math.min(...Object.values(this.imagesLoaded));
|
||||
|
@ -135,7 +143,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
get areImagesWiderThanWindow() {
|
||||
let [innerWidth, _] = this.getInnerDimensions();
|
||||
let [_, innerWidth] = this.getInnerDimensions();
|
||||
return this.webtoonImageWidth > (innerWidth || document.documentElement.clientWidth);
|
||||
}
|
||||
|
||||
|
@ -173,18 +181,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : window, 'scroll')
|
||||
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
||||
.subscribe((event) => this.handleScrollEvent(event));
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initScrollHandler();
|
||||
|
||||
this.recalculateImageWidth();
|
||||
|
||||
if (this.goToPage) {
|
||||
this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
|
||||
this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page);
|
||||
const isSamePage = this.pageNum === page;
|
||||
if (isSamePage) { return; }
|
||||
this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page);
|
||||
|
||||
if (this.pageNum < page) {
|
||||
this.scrollingDirection = PAGING_DIRECTION.FORWARD;
|
||||
|
@ -212,14 +220,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
this.fullscreenToggled.pipe(takeUntil(this.onDestroy)).subscribe(isFullscreen => {
|
||||
this.debugLog('[FullScreen] Fullscreen mode: ', isFullscreen);
|
||||
this.isFullscreenMode = isFullscreen;
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.recalculateImageWidth();
|
||||
this.initScrollHandler();
|
||||
this.setPageNum(this.pageNum, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
recalculateImageWidth() {
|
||||
const [_, innerWidth] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
}
|
||||
|
||||
getVerticalOffset() {
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window;
|
||||
|
||||
|
@ -252,10 +264,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
this.prevScrollPosition = verticalOffset;
|
||||
|
||||
console.log('CurrentPageElem: ', this.currentPageElem);
|
||||
if (this.currentPageElem != null) {
|
||||
console.log('Element Visible: ', this.isElementVisible(this.currentPageElem));
|
||||
}
|
||||
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
||||
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
|
||||
this.isScrolling = false;
|
||||
|
@ -336,6 +344,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns Height, Width
|
||||
*/
|
||||
getInnerDimensions() {
|
||||
let innerHeight = window.innerHeight;
|
||||
let innerWidth = window.innerWidth;
|
||||
|
@ -356,15 +368,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
isElementVisible(elem: Element) {
|
||||
if (elem === null || elem === undefined) { return false; }
|
||||
|
||||
this.debugLog('[Visibility] Checking if Page ' + elem.getAttribute('id') + ' is visible');
|
||||
// NOTE: This will say an element is visible if it is 1 px offscreen on top
|
||||
var rect = elem.getBoundingClientRect();
|
||||
|
||||
let [innerHeight, innerWidth] = this.getInnerDimensions();
|
||||
|
||||
|
||||
console.log('innerHeight: ', innerHeight);
|
||||
console.log('innerWidth: ', innerWidth);
|
||||
|
||||
return (rect.bottom >= 0 &&
|
||||
rect.right >= 0 &&
|
||||
rect.top <= (innerHeight || document.documentElement.clientHeight) &&
|
||||
|
@ -399,8 +408,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
|
||||
initWebtoonReader() {
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.initFinished = false;
|
||||
this.recalculateImageWidth();
|
||||
this.imagesLoaded = {};
|
||||
this.webtoonImages.next([]);
|
||||
this.atBottom = false;
|
||||
|
@ -437,11 +446,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
.filter((img: any) => !img.complete)
|
||||
.map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; })))
|
||||
.then(() => {
|
||||
this.debugLog('[Initialization] All images have loaded from initial prefetch, initFinished = true');
|
||||
this.debugLog('[Image Load] ! Loaded current page !', this.pageNum);
|
||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||
|
||||
// There needs to be a bit of time before we scroll
|
||||
if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) {
|
||||
this.scrollToCurrentPage();
|
||||
} else {
|
||||
this.initFinished = true;
|
||||
}
|
||||
|
||||
this.allImagesLoaded = true;
|
||||
|
@ -471,8 +483,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page
|
||||
*/
|
||||
setPageNum(pageNum: number, scrollToPage: boolean = false) {
|
||||
if (pageNum > this.totalPages) {
|
||||
pageNum = this.totalPages;
|
||||
if (pageNum >= this.totalPages) {
|
||||
pageNum = this.totalPages - 1;
|
||||
} else if (pageNum < 0) {
|
||||
pageNum = 0;
|
||||
}
|
||||
|
@ -482,9 +494,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
this.prefetchWebtoonImages();
|
||||
|
||||
if (scrollToPage) {
|
||||
const currentImage = document.querySelector('img#page-' + this.pageNum);
|
||||
if (currentImage === null) return;
|
||||
this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
|
||||
this.scrollToCurrentPage();
|
||||
}
|
||||
}
|
||||
|
@ -499,6 +508,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
scrollToCurrentPage() {
|
||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||
if (!this.currentPageElem) { return; }
|
||||
this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
|
||||
|
||||
// Update prevScrollPosition, so the next scroll event properly calculates direction
|
||||
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
|
||||
|
@ -508,6 +518,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
if (this.currentPageElem) {
|
||||
this.debugLog('[Scroll] Scrolling to page ', this.pageNum);
|
||||
this.currentPageElem.scrollIntoView({behavior: 'smooth'});
|
||||
this.initFinished = true;
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
@ -540,7 +551,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
attachIntersectionObserverElem(elem: HTMLImageElement) {
|
||||
if (elem !== null) {
|
||||
this.intersectionObserver.observe(elem);
|
||||
this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src));
|
||||
this.debugLog('[Intersection] Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src));
|
||||
} else {
|
||||
console.error('Could not attach observer on elem'); // This never happens
|
||||
}
|
||||
|
@ -610,6 +621,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
debugLog(message: string, extraData?: any) {
|
||||
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
||||
|
||||
if (this.debugLogFilter.filter(str => message.replace('\t', '').startsWith(str)).length > 0) return;
|
||||
if (extraData !== undefined) {
|
||||
console.log(message, extraData);
|
||||
} else {
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<canvas #content class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}"
|
||||
ondragstart="return false;" onselectstart="return false;">
|
||||
</canvas>
|
||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading && !inSetup">
|
||||
<app-infinite-scroller [pageNum]="pageNum"
|
||||
[bufferPages]="5"
|
||||
[goToPage]="goToPageEvent"
|
||||
|
|
|
@ -10,7 +10,7 @@ import { NavService } from '../_services/nav.service';
|
|||
import { ReadingDirection } from '../_models/preferences/reading-direction';
|
||||
import { ScalingOption } from '../_models/preferences/scaling-option';
|
||||
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
||||
import { forkJoin, ReplaySubject, Subject } from 'rxjs';
|
||||
import { BehaviorSubject, forkJoin, ReplaySubject, Subject } from 'rxjs';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { KEY_CODES, UtilityService, Breakpoint } from '../shared/_services/utility.service';
|
||||
import { CircularArray } from '../shared/data-structures/circular-array';
|
||||
|
@ -126,7 +126,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
/**
|
||||
* An event emiter when a page change occurs. Used soley by the webtoon reader.
|
||||
*/
|
||||
goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
goToPageEvent!: BehaviorSubject<number>;
|
||||
|
||||
/**
|
||||
* An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader.
|
||||
*/
|
||||
|
@ -221,6 +222,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
* Library Type used for rendering chapter or issue
|
||||
*/
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
/**
|
||||
* Used for webtoon reader. When loading pages or data, this will disable the reader
|
||||
*/
|
||||
inSetup: boolean = true;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
@ -400,6 +405,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.goToPage(parseInt(goToPageNum.trim(), 10));
|
||||
} else if (event.key === KEY_CODES.B) {
|
||||
this.bookmarkPage();
|
||||
} else if (event.key === KEY_CODES.F) {
|
||||
this.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -422,6 +429,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.nextChapterPrefetched = false;
|
||||
this.pageNum = 0;
|
||||
this.pagingDirection = PAGING_DIRECTION.FORWARD;
|
||||
this.inSetup = true;
|
||||
|
||||
if (this.goToPageEvent) {
|
||||
// There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicity clear it out between loads
|
||||
// and we use a BehaviourSubject to ensure only latest value is sent
|
||||
this.goToPageEvent.complete();
|
||||
}
|
||||
|
||||
forkJoin({
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
|
@ -443,6 +457,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
page = this.maxPages - 1;
|
||||
}
|
||||
this.setPageNum(page);
|
||||
this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -451,11 +467,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
|
||||
this.pageOptions = newOptions;
|
||||
|
||||
// TODO: Move this into ChapterInfo
|
||||
this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
this.updateTitle(results.chapterInfo, type);
|
||||
});
|
||||
|
||||
this.inSetup = false;
|
||||
|
||||
|
||||
|
||||
// From bookmarks, create map of pages to make lookup time O(1)
|
||||
|
@ -1017,7 +1036,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.setPageNum(page);
|
||||
this.refreshSlider.emit();
|
||||
this.goToPageEvent.next(page);
|
||||
this.goToPageEvent.next(page);
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<ng-container>
|
||||
<ng-container *ngIf="isAdmin">
|
||||
|
||||
<button type="button" class="btn btn-icon {{(progressEventsSource.getValue().length > 0 || updateAvailable) ? 'colored' : ''}}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
@use "../../theme/colors";
|
||||
|
||||
|
||||
.btn:focus, .btn:hover {
|
||||
box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1); // TODO: Used in nav as well, move to dark for btn-icon focus
|
||||
}
|
||||
|
||||
.small-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
|
|
@ -5,6 +5,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
||||
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
|
||||
import { User } from '../_models/user';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
|
||||
|
||||
|
@ -28,6 +29,7 @@ const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgre
|
|||
export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() user!: User;
|
||||
isAdmin: boolean = false;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
@ -41,7 +43,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
updateBody: any;
|
||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private messageHub: MessageHubService, private libraryService: LibraryService, private modalService: NgbModal) { }
|
||||
constructor(private messageHub: MessageHubService, private libraryService: LibraryService, private modalService: NgbModal, private accountService: AccountService) { }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
|
@ -58,13 +60,18 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
this.updateBody = event.payload;
|
||||
}
|
||||
});
|
||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
} else {
|
||||
this.isAdmin = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
processProgressEvent(event: Message<ProgressEvent>, eventType: string) {
|
||||
const scanEvent = event.payload as ProgressEvent;
|
||||
console.log(event.event, event.payload);
|
||||
|
||||
|
||||
this.libraryService.getLibraryNames().subscribe(names => {
|
||||
const data = this.progressEventsSource.getValue();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<nav class="navbar navbar-expand-md navbar-dark fixed-top" *ngIf="navService?.navbarVisible$ | async">
|
||||
<div class="container-fluid">
|
||||
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
|
||||
<a class="navbar-brand" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="phone-hidden"> Kavita</span></a>
|
||||
<a class="navbar-brand dark-exempt" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="phone-hidden"> Kavita</span></a>
|
||||
<ul class="navbar-nav col mr-auto">
|
||||
|
||||
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
|
||||
|
@ -10,44 +10,100 @@
|
|||
<div class="form-group" style="margin-bottom: 0px;">
|
||||
<label for="nav-search" class="sr-only">Search series</label>
|
||||
<div class="ng-autocomplete">
|
||||
<ng-autocomplete
|
||||
#search
|
||||
id="nav-search"
|
||||
[classList]="['ng-autocomplete']"
|
||||
[data]="searchResults"
|
||||
searchKeyword="name"
|
||||
placeholder="Search Series"
|
||||
[initialValue]=""
|
||||
[focusFirst]="true"
|
||||
[minQueryLength]="2"
|
||||
(selected)='clickSearchResult($event)'
|
||||
(inputChanged)='onChangeSearch($event)'
|
||||
[isLoading]="isLoading"
|
||||
[customFilter]="customFilter"
|
||||
[debounceTime]="debounceTime"
|
||||
[itemTemplate]="itemTemplate"
|
||||
[notFoundTemplate]="notFoundTemplate">
|
||||
</ng-autocomplete>
|
||||
|
||||
<ng-template #itemTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;">
|
||||
<div style="width: 24px" class="mr-1">
|
||||
<img class="mr-3 search-result" src="{{imageService.getSeriesCoverImage(item.seriesId)}}">
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
<app-series-format [format]="item.format"></app-series-format>
|
||||
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName" [innerHTML]="item.name"></span>
|
||||
<ng-template #localizedName>
|
||||
<span [innerHTML]="item.localizedName"></span>
|
||||
</ng-template>
|
||||
<span class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</span>
|
||||
</div>
|
||||
<app-grouped-typeahead
|
||||
#search
|
||||
id="nav-search"
|
||||
[minQueryLength]="2"
|
||||
initialValue=""
|
||||
placeholder="Search…"
|
||||
[grouppedData]="searchResults"
|
||||
(inputChanged)="onChangeSearch($event)"
|
||||
(clearField)="clearSearch()"
|
||||
(focusChanged)="focusUpdate($event)"
|
||||
>
|
||||
|
||||
<ng-template #libraryTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
|
||||
<div class="ml-1">
|
||||
<span>{{item.name}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #notFoundTemplate let-notFound>
|
||||
No results found
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #seriesTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
|
||||
<div style="width: 24px" class="mr-1">
|
||||
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
<app-series-format [format]="item.format"></app-series-format>
|
||||
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
|
||||
<ng-template #localizedName>
|
||||
<span [innerHTML]="item.localizedName"></span>
|
||||
</ng-template>
|
||||
<span class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #collectionTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
|
||||
<div style="width: 24px" class="mr-1">
|
||||
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
<span>{{item.title}}</span>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #readingListTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
|
||||
<div class="ml-1">
|
||||
<span>{{item.title}}</span>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #tagTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
|
||||
<div class="ml-1">
|
||||
<span>{{item.title}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #personTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
|
||||
<div class="ml-1">
|
||||
|
||||
<div [innerHTML]="item.name"></div>
|
||||
<div>{{item.role | personRole}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #genreTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
|
||||
<div class="ml-1">
|
||||
<div [innerHTML]="item.title"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noResultsTemplate let-notFound>
|
||||
No results found
|
||||
</ng-template>
|
||||
|
||||
</app-grouped-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
@ -55,27 +111,38 @@
|
|||
</div>
|
||||
</ul>
|
||||
|
||||
<div class="back-to-top">
|
||||
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
|
||||
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
|
||||
<span class="sr-only">Scroll to Top</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
|
||||
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
|
||||
</div>
|
||||
|
||||
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
|
||||
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
|
||||
{{user.username | sentenceCase}}
|
||||
</button>
|
||||
<div ngbDropdownMenu>
|
||||
<a ngbDropdownItem routerLink="/preferences/">User Settings</a>
|
||||
<a ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</a>
|
||||
<a ngbDropdownItem (click)="logout()">Logout</a>
|
||||
<ng-container *ngIf="!searchFocused">
|
||||
<div class="back-to-top">
|
||||
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
|
||||
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
|
||||
<span class="sr-only">Scroll to Top</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="(accountService.currentUser$ | async) as user">
|
||||
<div class="nav-item">
|
||||
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
|
||||
</div>
|
||||
<div class="nav-item pr-2 not-xs-only">
|
||||
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt btn btn-icon">
|
||||
<i class="fa fa-cogs" aria-hidden="true" style="color: white"></i>
|
||||
<span class="sr-only">Server Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
|
||||
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
|
||||
{{user.username | sentenceCase}}
|
||||
</button>
|
||||
<div ngbDropdownMenu>
|
||||
<a class="xs-only" ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</a>
|
||||
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
|
||||
<a ngbDropdownItem (click)="logout()">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -3,10 +3,41 @@
|
|||
$primary-color: white;
|
||||
$bg-color: rgb(22, 27, 34);
|
||||
|
||||
.btn:focus, .btn:hover {
|
||||
box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
/* small devices (phones, 650px and down) */
|
||||
@media only screen and (max-width:650px) { //370
|
||||
.navbar-nav {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// On Really small screens, hide the server settings wheel and show it in nav
|
||||
.xs-only {
|
||||
display: none;
|
||||
}
|
||||
.not-xs-only {
|
||||
display: inherit;
|
||||
}
|
||||
@media only screen and (max-width:300px) {
|
||||
.xs-only {
|
||||
display: inherit;
|
||||
}
|
||||
.not-xs-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item.dropdown {
|
||||
position: unset;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-family: "Spartan", sans-serif;
|
||||
font-weight: bold;
|
||||
|
@ -28,7 +59,6 @@ $bg-color: rgb(22, 27, 34);
|
|||
|
||||
.ng-autocomplete {
|
||||
margin-bottom: 0px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.primary-text {
|
||||
|
@ -41,18 +71,21 @@ $bg-color: rgb(22, 27, 34);
|
|||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.form-inline .form-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.form-inline .form-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {
|
||||
.ng-autocomplete {
|
||||
width: 100%; // 232px
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 300px and down) */
|
||||
@media only screen and (max-width: 300px) { //370
|
||||
.ng-autocomplete {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-to-top:hover {
|
||||
animation: MoveUpDown 1s linear infinite;
|
||||
|
|
|
@ -3,9 +3,13 @@ import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@
|
|||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { isTemplateSpan } from 'typescript';
|
||||
import { ScrollService } from '../scroll.service';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Library } from '../_models/library';
|
||||
import { PersonRole } from '../_models/person';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
import { SearchResult } from '../_models/search-result';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { ImageService } from '../_services/image.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
|
@ -23,7 +27,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||
isLoading = false;
|
||||
debounceTime = 300;
|
||||
imageStyles = {width: '24px', 'margin-top': '5px'};
|
||||
searchResults: SearchResult[] = [];
|
||||
searchResults: SearchResultGroup = new SearchResultGroup();
|
||||
searchTerm = '';
|
||||
customFilter: (items: SearchResult[], query: string) => SearchResult[] = (items: SearchResult[], query: string) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
@ -38,6 +42,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||
|
||||
|
||||
backToTopNeeded = false;
|
||||
searchFocused: boolean = false;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||
|
@ -78,31 +83,104 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
moveFocus() {
|
||||
document.getElementById('content')?.focus();
|
||||
this.document.getElementById('content')?.focus();
|
||||
}
|
||||
|
||||
|
||||
|
||||
onChangeSearch(val: string) {
|
||||
this.isLoading = true;
|
||||
this.searchTerm = val.trim();
|
||||
this.libraryService.search(val).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
|
||||
this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
this.searchResults = results;
|
||||
this.isLoading = false;
|
||||
}, err => {
|
||||
this.searchResults = [];
|
||||
this.searchResults.reset();
|
||||
this.isLoading = false;
|
||||
this.searchTerm = '';
|
||||
});
|
||||
}
|
||||
|
||||
clickSearchResult(item: SearchResult) {
|
||||
goTo(queryParamName: string, filter: any) {
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params['page'] = 1;
|
||||
this.clearSearch();
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
}
|
||||
|
||||
goToPerson(role: PersonRole, filter: any) {
|
||||
// TODO: Move this to utility service
|
||||
this.clearSearch();
|
||||
switch(role) {
|
||||
case PersonRole.Writer:
|
||||
this.goTo('writers', filter);
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
this.goTo('artists', filter);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.goTo('character', filter);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.goTo('colorist', filter);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.goTo('editor', filter);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.goTo('inker', filter);
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
this.goTo('coverArtists', filter);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.goTo('inker', filter);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.goTo('letterer', filter);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.goTo('penciller', filter);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.goTo('publisher', filter);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.goTo('translator', filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchViewRef.clear();
|
||||
this.searchTerm = '';
|
||||
this.searchResults = new SearchResultGroup();
|
||||
}
|
||||
|
||||
clickSeriesSearchResult(item: SearchResult) {
|
||||
this.clearSearch();
|
||||
const libraryId = item.libraryId;
|
||||
const seriesId = item.seriesId;
|
||||
this.searchViewRef.clear();
|
||||
this.searchResults = [];
|
||||
this.searchTerm = '';
|
||||
this.router.navigate(['library', libraryId, 'series', seriesId]);
|
||||
}
|
||||
|
||||
clickLibraryResult(item: Library) {
|
||||
this.router.navigate(['library', item.id]);
|
||||
}
|
||||
|
||||
clickCollectionSearchResult(item: CollectionTag) {
|
||||
this.clearSearch();
|
||||
this.router.navigate(['collections', item.id]);
|
||||
}
|
||||
|
||||
clickReadingListSearchResult(item: ReadingList) {
|
||||
this.clearSearch();
|
||||
this.router.navigate(['lists', item.id]);
|
||||
}
|
||||
|
||||
|
||||
scrollToTop() {
|
||||
window.scroll({
|
||||
top: 0,
|
||||
|
@ -110,5 +188,10 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
focusUpdate(searchFocused: boolean) {
|
||||
this.searchFocused = searchFocused
|
||||
return searchFocused;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.c
|
|||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter} from '../_models/series-filter';
|
||||
import { FilterEvent, SeriesFilter} from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
@ -63,9 +63,10 @@ export class OnDeckComponent implements OnInit {
|
|||
this.loadPage();
|
||||
}
|
||||
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
updateFilter(event: FilterEvent) {
|
||||
this.filter = event.filter;
|
||||
const page = this.getPage();
|
||||
if (page === undefined || page === null || !event.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
|
|
|
@ -11,7 +11,7 @@ export class PersonRolePipe implements PipeTransform {
|
|||
case PersonRole.Artist: return 'Artist';
|
||||
case PersonRole.Character: return 'Character';
|
||||
case PersonRole.Colorist: return 'Colorist';
|
||||
case PersonRole.CoverArtist: return 'CoverArtist';
|
||||
case PersonRole.CoverArtist: return 'Cover Artist';
|
||||
case PersonRole.Editor: return 'Editor';
|
||||
case PersonRole.Inker: return 'Inker';
|
||||
case PersonRole.Letterer: return 'Letterer';
|
||||
|
|
|
@ -8,7 +8,7 @@ export class PublicationStatusPipe implements PipeTransform {
|
|||
|
||||
transform(value: PublicationStatus): string {
|
||||
switch (value) {
|
||||
case PublicationStatus.OnGoing: return 'On Going';
|
||||
case PublicationStatus.OnGoing: return 'Ongoing';
|
||||
case PublicationStatus.Hiatus: return 'Hiatus';
|
||||
case PublicationStatus.Completed: return 'Completed';
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="container-sm mt-2" *ngIf="readingList">
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
<div class="mb-3">
|
||||
<!-- Title row-->
|
||||
<div class="row no-gutters">
|
||||
|
@ -49,8 +49,7 @@
|
|||
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
|
||||
<ng-template #draggableItem let-item let-position="idx">
|
||||
<div class="media" style="width: 100%;">
|
||||
<img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)"
|
||||
(error)="imageService.updateErroredImage($event)">
|
||||
<app-image width="74px" class="img-top mr-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}
|
||||
<span class="badge badge-primary badge-pill">
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
[items]="lists"
|
||||
[actions]="actions"
|
||||
[pagination]="pagination"
|
||||
[filteringDisabled]="true"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
|
|
@ -9,7 +9,7 @@ import { KEY_CODES } from '../shared/_services/utility.service';
|
|||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { FilterEvent, SeriesFilter } from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { MessageHubService } from '../_services/message-hub.service';
|
||||
|
@ -23,6 +23,7 @@ import { SeriesService } from '../_services/series.service';
|
|||
templateUrl: './recently-added.component.html',
|
||||
styleUrls: ['./recently-added.component.scss']
|
||||
})
|
||||
|
||||
export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
||||
|
||||
isLoading: boolean = true;
|
||||
|
@ -81,9 +82,10 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||
this.loadPage();
|
||||
}
|
||||
|
||||
applyFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
applyFilter(event: FilterEvent) {
|
||||
this.filter = event.filter;
|
||||
const page = this.getPage();
|
||||
if (page === undefined || page === null || !event.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
|
||||
<div class="text-danger" *ngIf="errors.length > 0">
|
||||
<p>Errors:</p>
|
||||
<ul>
|
||||
<li *ngFor="let error of errors">{{error}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="register()">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
</div>
|
||||
|
||||
<div class="form-group" *ngIf="registerForm.get('isAdmin')?.value || !authDisabled">
|
||||
<label for="password">Password</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #passwordTooltip>
|
||||
Password must be between 6 and 32 characters in length
|
||||
</ng-template>
|
||||
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
||||
<input id="password" class="form-control" formControlName="password" type="password" aria-describedby="password-help">
|
||||
</div>
|
||||
|
||||
<div class="form-check" *ngIf="!firstTimeFlow">
|
||||
<input id="admin" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="isAdmin">
|
||||
<label for="admin" class="form-check-label">Admin</label>
|
||||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-secondary mr-2" type="button" (click)="cancel()" *ngIf="!firstTimeFlow">Cancel</button>
|
||||
<button class="btn btn-primary {{firstTimeFlow ? 'alt' : ''}}" type="submit">Register</button>
|
||||
</div>
|
||||
</form>
|
|
@ -1,18 +0,0 @@
|
|||
.alt {
|
||||
background-color: #424c72;
|
||||
border-color: #444f75;
|
||||
}
|
||||
|
||||
.alt:hover {
|
||||
background-color: #3b4466;
|
||||
}
|
||||
|
||||
.alt:focus {
|
||||
background-color: #343c59;
|
||||
box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%);
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: #fff !important;
|
||||
color: black !important;
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { SettingsService } from '../admin/settings.service';
|
||||
import { User } from '../_models/user';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register-member',
|
||||
templateUrl: './register-member.component.html',
|
||||
styleUrls: ['./register-member.component.scss']
|
||||
})
|
||||
export class RegisterMemberComponent implements OnInit {
|
||||
|
||||
@Input() firstTimeFlow = false;
|
||||
/**
|
||||
* Emits the new user created.
|
||||
*/
|
||||
@Output() created = new EventEmitter<User | null>();
|
||||
|
||||
adminExists = false;
|
||||
authDisabled: boolean = false;
|
||||
registerForm: FormGroup = new FormGroup({
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', []),
|
||||
isAdmin: new FormControl(false, [])
|
||||
});
|
||||
errors: string[] = [];
|
||||
|
||||
constructor(private accountService: AccountService, private settingsService: SettingsService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe(authEnabled => {
|
||||
this.authDisabled = !authEnabled;
|
||||
});
|
||||
if (this.firstTimeFlow) {
|
||||
this.registerForm.get('isAdmin')?.setValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
this.accountService.register(this.registerForm.value).subscribe(user => {
|
||||
this.created.emit(user);
|
||||
}, err => {
|
||||
this.errors = err;
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.created.emit(null);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Account Migration</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Your account does not have an email on file. This is a one-time migration. Please add your email to the account. A verficiation link will be sent to your email for you
|
||||
to confirm and will then be allowed to authenticate with this server. This is required.
|
||||
</p>
|
||||
|
||||
<p class="text-danger" *ngIf="error.length > 0">{{error}}</p>
|
||||
|
||||
<form [formGroup]="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="width:100%">
|
||||
<label for="email">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required>
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="registerForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('password')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !registerForm.valid">
|
||||
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1,66 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { SafeUrl } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-email-to-account-migration-modal',
|
||||
templateUrl: './add-email-to-account-migration-modal.component.html',
|
||||
styleUrls: ['./add-email-to-account-migration-modal.component.scss']
|
||||
})
|
||||
export class AddEmailToAccountMigrationModalComponent implements OnInit {
|
||||
|
||||
@Input() username!: string;
|
||||
@Input() password!: string;
|
||||
|
||||
isSaving: boolean = false;
|
||||
registerForm: FormGroup = new FormGroup({});
|
||||
emailLink: string = '';
|
||||
emailLinkUrl: SafeUrl | undefined;
|
||||
error: string = '';
|
||||
|
||||
constructor(private accountService: AccountService, private modal: NgbActiveModal,
|
||||
private serverService: ServerService, private confirmService: ConfirmService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.registerForm.addControl('username', new FormControl(this.username, [Validators.required]));
|
||||
this.registerForm.addControl('email', new FormControl('', [Validators.required, Validators.email]));
|
||||
this.registerForm.addControl('password', new FormControl(this.password, [Validators.required]));
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(false);
|
||||
}
|
||||
|
||||
save() {
|
||||
this.serverService.isServerAccessible().subscribe(canAccess => {
|
||||
const model = this.registerForm.getRawValue();
|
||||
model.sendEmail = canAccess;
|
||||
this.accountService.migrateUser(model).subscribe(async (email) => {
|
||||
console.log(email);
|
||||
if (!canAccess) {
|
||||
// Display the email to the user
|
||||
this.emailLink = email;
|
||||
await this.confirmService.alert('Please click this link to confirm your email. You must confirm to be able to login. The link is in your logs. You may need to log out of the current account before clicking. <br/> <a href="' + this.emailLink + '" target="_blank">' + this.emailLink + '</a>');
|
||||
this.modal.close(true);
|
||||
} else {
|
||||
await this.confirmService.alert('Please check your email (or logs under "Email Link") for the confirmation link. You must confirm to be able to login.');
|
||||
this.modal.close(true);
|
||||
}
|
||||
}, err => {
|
||||
this.error = err;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>Register</h2></ng-container>
|
||||
<ng-container body>
|
||||
<p>Complete the form to complete your registration</p>
|
||||
<div class="text-danger" *ngIf="errors.length > 0">
|
||||
<p>Errors:</p>
|
||||
<ul>
|
||||
<li *ngFor="let error of errors">{{error}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="width:100%">
|
||||
<label for="email">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required>
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="registerForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #passwordTooltip>
|
||||
Password must be between 6 and 32 characters in length
|
||||
</ng-template>
|
||||
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('password')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength">
|
||||
Password must be between 6 and 32 characters in length
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-secondary alt" type="submit">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</app-splash-container>
|
|
@ -0,0 +1,4 @@
|
|||
input {
|
||||
background-color: #fff !important;
|
||||
color: black;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-email',
|
||||
templateUrl: './confirm-email.component.html',
|
||||
styleUrls: ['./confirm-email.component.scss']
|
||||
})
|
||||
export class ConfirmEmailComponent implements OnInit {
|
||||
|
||||
|
||||
/**
|
||||
* Email token used for validating
|
||||
*/
|
||||
token: string = '';
|
||||
|
||||
registerForm: FormGroup = new FormGroup({
|
||||
email: new FormControl('', [Validators.required, Validators.email]),
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation errors from API
|
||||
*/
|
||||
errors: Array<string> = [];
|
||||
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {
|
||||
|
||||
const token = this.route.snapshot.queryParamMap.get('token');
|
||||
const email = this.route.snapshot.queryParamMap.get('email');
|
||||
if (token == undefined || token === '' || token === null) {
|
||||
// This is not a valid url, redirect to login
|
||||
this.toastr.error('Invalid confirmation email');
|
||||
this.router.navigateByUrl('login');
|
||||
return;
|
||||
}
|
||||
this.token = token;
|
||||
this.registerForm.get('email')?.setValue(email || '');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
submit() {
|
||||
let model = this.registerForm.getRawValue();
|
||||
model.token = this.token;
|
||||
this.accountService.confirmEmail(model).subscribe((user) => {
|
||||
this.toastr.success('Account registration complete');
|
||||
this.router.navigateByUrl('login');
|
||||
}, err => {
|
||||
console.log('error: ', err);
|
||||
this.errors = err;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue