Merged develop into main
This commit is contained in:
commit
aa710529f0
151 changed files with 4393 additions and 1703 deletions
10
UI/Web/src/app/_models/config-data.ts
Normal file
10
UI/Web/src/app/_models/config-data.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* This is for base url only. Not to be used my applicaiton, only loading and bootstrapping app
|
||||
*/
|
||||
export class ConfigData {
|
||||
baseUrl: string = '/';
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
}
|
4
UI/Web/src/app/_models/events/refresh-metadata-event.ts
Normal file
4
UI/Web/src/app/_models/events/refresh-metadata-event.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface RefreshMetadataEvent {
|
||||
libraryId: number;
|
||||
seriesId: number;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export interface ScanLibraryProgressEvent {
|
||||
libraryId: number;
|
||||
progress: number;
|
||||
eventTime: string;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export interface ScanSeriesEvent {
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
}
|
5
UI/Web/src/app/_models/events/series-added-event.ts
Normal file
5
UI/Web/src/app/_models/events/series-added-event.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface SeriesAddedEvent {
|
||||
libraryId: number;
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface SeriesAddedToCollectionEvent {
|
||||
tagId: number;
|
||||
seriesId: number;
|
||||
}
|
|
@ -7,7 +7,7 @@ export enum LibraryType {
|
|||
export interface Library {
|
||||
id: number;
|
||||
name: string;
|
||||
coverImage: string;
|
||||
lastScanned: string;
|
||||
type: LibraryType;
|
||||
folders: string[];
|
||||
}
|
|
@ -5,10 +5,8 @@ import { map, takeUntil } from 'rxjs/operators';
|
|||
import { environment } from 'src/environments/environment';
|
||||
import { Preferences } from '../_models/preferences/preferences';
|
||||
import { User } from '../_models/user';
|
||||
import * as Sentry from "@sentry/angular";
|
||||
import { Router } from '@angular/router';
|
||||
import { MessageHubService } from './message-hub.service';
|
||||
import { PresenceHubService } from './presence-hub.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -17,6 +15,7 @@ export class AccountService implements OnDestroy {
|
|||
|
||||
baseUrl = environment.apiUrl;
|
||||
userKey = 'kavita-user';
|
||||
public lastLoginKey = 'kavita-lastlogin';
|
||||
currentUser: User | undefined;
|
||||
|
||||
// Stores values, when someone subscribes gives (1) of last values seen.
|
||||
|
@ -26,7 +25,7 @@ export class AccountService implements OnDestroy {
|
|||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router,
|
||||
private messageHub: MessageHubService, private presenceHub: PresenceHubService) {}
|
||||
private messageHub: MessageHubService) {}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
|
@ -51,8 +50,7 @@ export class AccountService implements OnDestroy {
|
|||
const user = response;
|
||||
if (user) {
|
||||
this.setCurrentUser(user);
|
||||
this.messageHub.createHubConnection(user);
|
||||
this.presenceHub.createHubConnection(user);
|
||||
this.messageHub.createHubConnection(user, this.hasAdminRole(user));
|
||||
}
|
||||
}),
|
||||
takeUntil(this.onDestroy)
|
||||
|
@ -64,14 +62,9 @@ export class AccountService implements OnDestroy {
|
|||
user.roles = [];
|
||||
const roles = this.getDecodedToken(user.token).role;
|
||||
Array.isArray(roles) ? user.roles = roles : user.roles.push(roles);
|
||||
Sentry.setContext('admin', {'admin': this.hasAdminRole(user)});
|
||||
Sentry.configureScope(scope => {
|
||||
scope.setUser({
|
||||
username: user.username
|
||||
});
|
||||
});
|
||||
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
localStorage.setItem(this.lastLoginKey, user.username);
|
||||
}
|
||||
|
||||
this.currentUserSource.next(user);
|
||||
|
@ -85,7 +78,6 @@ export class AccountService implements OnDestroy {
|
|||
// Upon logout, perform redirection
|
||||
this.router.navigateByUrl('/login');
|
||||
this.messageHub.stopHubConnection();
|
||||
this.presenceHub.stopHubConnection();
|
||||
}
|
||||
|
||||
register(model: {username: string, password: string, isAdmin?: boolean}) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { take } from 'rxjs/operators';
|
|||
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
||||
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { Library } from '../_models/library';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
|
@ -35,7 +36,8 @@ export class ActionService implements OnDestroy {
|
|||
private readingListModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal) { }
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
||||
private confirmService: ConfirmService) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
|
@ -66,11 +68,15 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
* @returns
|
||||
*/
|
||||
refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
|
||||
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
|
||||
if (!library.hasOwnProperty('id') || library.id === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Scan started for ' + library.name);
|
||||
if (callback) {
|
||||
|
@ -128,7 +134,11 @@ export class ActionService implements OnDestroy {
|
|||
* @param series Series, must have libraryId, id and name populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
refreshMetdata(series: Series, callback?: SeriesActionCallback) {
|
||||
async refreshMetdata(series: Series, callback?: SeriesActionCallback) {
|
||||
if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Refresh started for ' + series.name);
|
||||
if (callback) {
|
||||
|
@ -235,10 +245,10 @@ export class ActionService implements OnDestroy {
|
|||
markMultipleAsUnread(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => {
|
||||
volumes.forEach(volume => {
|
||||
volume.pagesRead = volume.pages;
|
||||
volume.chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
volume.pagesRead = 0;
|
||||
volume.chapters?.forEach(c => c.pagesRead = 0);
|
||||
});
|
||||
chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
chapters?.forEach(c => c.pagesRead = 0);
|
||||
this.toastr.success('Marked as Read');
|
||||
|
||||
if (callback) {
|
||||
|
|
|
@ -16,6 +16,10 @@ export class MemberService {
|
|||
return this.httpClient.get<Member[]>(this.baseUrl + 'users');
|
||||
}
|
||||
|
||||
getMemberNames() {
|
||||
return this.httpClient.get<string[]>(this.baseUrl + 'users/names');
|
||||
}
|
||||
|
||||
adminExists() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'admin/exists');
|
||||
}
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import { EventEmitter, Injectable } from '@angular/core';
|
||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { User } from '@sentry/angular';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { BehaviorSubject, ReplaySubject } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
||||
import { ScanLibraryEvent } from '../_models/events/scan-library-event';
|
||||
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
|
||||
import { ScanLibraryProgressEvent } from '../_models/events/scan-library-progress-event';
|
||||
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { User } from '../_models/user';
|
||||
|
||||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable',
|
||||
ScanSeries = 'ScanSeries',
|
||||
ScanLibrary = 'ScanLibrary',
|
||||
RefreshMetadata = 'RefreshMetadata',
|
||||
SeriesAdded = 'SeriesAdded',
|
||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||
OnlineUsers = 'OnlineUsers',
|
||||
SeriesAddedToCollection = 'SeriesAddedToCollection'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
|
@ -31,12 +37,23 @@ export class MessageHubService {
|
|||
private messagesSource = new ReplaySubject<Message<any>>(1);
|
||||
public messages$ = this.messagesSource.asObservable();
|
||||
|
||||
private onlineUsersSource = new BehaviorSubject<string[]>([]);
|
||||
onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||
|
||||
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
|
||||
public scanLibrary: EventEmitter<ScanLibraryEvent> = new EventEmitter<ScanLibraryEvent>();
|
||||
public scanLibrary: EventEmitter<ScanLibraryProgressEvent> = new EventEmitter<ScanLibraryProgressEvent>();
|
||||
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
|
||||
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
|
||||
|
||||
constructor(private modalService: NgbModal) { }
|
||||
isAdmin: boolean = false;
|
||||
|
||||
constructor(private modalService: NgbModal, private toastr: ToastrService) {
|
||||
|
||||
}
|
||||
|
||||
createHubConnection(user: User, isAdmin: boolean) {
|
||||
this.isAdmin = isAdmin;
|
||||
|
||||
createHubConnection(user: User) {
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl + 'messages', {
|
||||
accessTokenFactory: () => user.token
|
||||
|
@ -48,10 +65,11 @@ export class MessageHubService {
|
|||
.start()
|
||||
.catch(err => console.error(err));
|
||||
|
||||
this.hubConnection.on('receiveMessage', body => {
|
||||
//console.log('[Hub] Body: ', body);
|
||||
this.hubConnection.on(EVENTS.OnlineUsers, (usernames: string[]) => {
|
||||
this.onlineUsersSource.next(usernames);
|
||||
});
|
||||
|
||||
|
||||
this.hubConnection.on(EVENTS.ScanSeries, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.ScanSeries,
|
||||
|
@ -60,15 +78,38 @@ export class MessageHubService {
|
|||
this.scanSeries.emit(resp.body);
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.ScanLibrary, resp => {
|
||||
this.hubConnection.on(EVENTS.ScanLibraryProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.ScanLibrary,
|
||||
event: EVENTS.ScanLibraryProgress,
|
||||
payload: resp.body
|
||||
});
|
||||
this.scanLibrary.emit(resp.body);
|
||||
// if ((resp.body as ScanLibraryEvent).stage === 'complete') {
|
||||
// this.toastr.
|
||||
// }
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.SeriesAddedToCollection,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SeriesAdded, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.SeriesAdded,
|
||||
payload: resp.body
|
||||
});
|
||||
this.seriesAdded.emit(resp.body);
|
||||
if (this.isAdmin) {
|
||||
this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added');
|
||||
}
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.RefreshMetadata, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.RefreshMetadata,
|
||||
payload: resp.body
|
||||
});
|
||||
this.refreshMetadata.emit(resp.body);
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.UpdateAvailable, resp => {
|
||||
|
@ -90,7 +131,9 @@ export class MessageHubService {
|
|||
}
|
||||
|
||||
stopHubConnection() {
|
||||
this.hubConnection.stop().catch(err => console.error(err));
|
||||
if (this.hubConnection) {
|
||||
this.hubConnection.stop().catch(err => console.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(methodName: string, body?: any) {
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||
import { User } from '@sentry/angular';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PresenceHubService {
|
||||
|
||||
hubUrl = environment.hubUrl;
|
||||
private hubConnection!: HubConnection;
|
||||
private onlineUsersSource = new BehaviorSubject<string[]>([]);
|
||||
onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||
|
||||
constructor(private toatsr: ToastrService) { }
|
||||
|
||||
createHubConnection(user: User) {
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl + 'presence', {
|
||||
accessTokenFactory: () => user.token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
this.hubConnection
|
||||
.start()
|
||||
.catch(err => console.error(err));
|
||||
|
||||
this.hubConnection.on('GetOnlineUsers', (usernames: string[]) => {
|
||||
this.onlineUsersSource.next(usernames);
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
this.hubConnection.stop().catch(err => console.error(err));
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<form [formGroup]="resetPasswordForm">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Reset {{member.username | titlecase}}'s Password</h4>
|
||||
<h4 class="modal-title" id="modal-basic-title">Reset {{member.username | sentenceCase}}'s Password</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
|
|
|
@ -6,4 +6,6 @@ export interface ServerSettings {
|
|||
port: number;
|
||||
allowStatCollection: boolean;
|
||||
enableOpds: boolean;
|
||||
enableAuthentication: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.fragment === ''">
|
||||
<app-manage-settings></app-manage-settings>
|
||||
|
|
|
@ -4,19 +4,29 @@
|
|||
<div class="col-4"><button class="btn btn-primary float-right" (click)="addLibrary()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden"> Add Library</span></button></div>
|
||||
</div>
|
||||
<ul class="list-group" *ngIf="!createLibraryToggle; else createLibrary">
|
||||
<li *ngFor="let library of libraries; let idx = index;" class="list-group-item">
|
||||
<li *ngFor="let library of libraries; let idx = index; trackby: trackbyLibrary" class="list-group-item">
|
||||
<div>
|
||||
<h4>
|
||||
<span id="library-name--{{idx}}">{{library.name | titlecase}}</span>
|
||||
<span id="library-name--{{idx}}">{{library.name}}</span>
|
||||
<div class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" *ngIf="scanInProgress.hasOwnProperty(library.id) && scanInProgress[library.id].progress" title="Scan in progress. Started at {{scanInProgress[library.id].timestamp | date: 'short'}}">
|
||||
<span class="sr-only">Scan for {{library.name}} in progress</span>
|
||||
</div>
|
||||
<div class="float-right">
|
||||
<button class="btn btn-secondary mr-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
|
||||
<button class="btn btn-danger mr-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | titlecase}}"></i></button>
|
||||
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{library.name | titlecase}}"></i></button>
|
||||
<button class="btn btn-danger mr-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | sentenceCase}}"></i></button>
|
||||
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{library.name | sentenceCase}}"></i></button>
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div>Type: {{libraryType(library.type)}}</div>
|
||||
<div>Shared Folders: {{library.folders.length + ' folders'}}</div>
|
||||
<div>
|
||||
Last Scanned:
|
||||
<span *ngIf="library.lastScanned == '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{library.lastScanned | date: 'short'}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loading" class="list-group-item">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
|
|
|
@ -4,8 +4,10 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { Subject } from 'rxjs';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { ScanLibraryProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
|
||||
import { Library, LibraryType } from 'src/app/_models/library';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component';
|
||||
|
||||
@Component({
|
||||
|
@ -22,13 +24,38 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||
* If a deletion is in progress for a library
|
||||
*/
|
||||
deletionInProgress: boolean = false;
|
||||
scanInProgress: {[key: number]: {progress: boolean, timestamp?: string}} = {};
|
||||
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private modalService: NgbModal, private libraryService: LibraryService, private toastr: ToastrService, private confirmService: ConfirmService) { }
|
||||
constructor(private modalService: NgbModal, private libraryService: LibraryService,
|
||||
private toastr: ToastrService, private confirmService: ConfirmService,
|
||||
private hubService: MessageHubService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getLibraries();
|
||||
|
||||
// when a progress event comes in, show it on the UI next to library
|
||||
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (event.event != EVENTS.ScanLibraryProgress) return;
|
||||
|
||||
const scanEvent = event.payload as ScanLibraryProgressEvent;
|
||||
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
||||
if (scanEvent.progress === 0) {
|
||||
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;
|
||||
}
|
||||
|
||||
if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 100) {
|
||||
this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => {
|
||||
const newLibrary = libraries.find(lib => lib.id === scanEvent.libraryId);
|
||||
const existingLibrary = this.libraries.find(lib => lib.id === scanEvent.libraryId);
|
||||
if (existingLibrary !== undefined) {
|
||||
existingLibrary.lastScanned = newLibrary?.lastScanned || existingLibrary.lastScanned;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<p class="text-warning pt-2">Port and Logging Level require a manual restart of Kavita to take effect.</p>
|
||||
<p class="text-warning pt-2">Port, Base Url, and Logging Level require a manual restart of Kavita to take effect.</p>
|
||||
<div class="form-group">
|
||||
<label for="settings-cachedir">Cache Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
|
||||
|
@ -8,6 +8,13 @@
|
|||
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
|
||||
</div>
|
||||
|
||||
<!-- <div class="form-group">
|
||||
<label for="settings-baseurl">Base Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
|
||||
<span class="sr-only" id="settings-baseurl-help">Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</span>
|
||||
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
|
||||
</div> -->
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-port">Port</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
|
||||
|
@ -42,6 +49,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
|||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
|
||||
|
@ -17,7 +18,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
taskFrequencies: Array<string> = [];
|
||||
logLevels: Array<string> = [];
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
|
||||
|
@ -35,6 +36,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]));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -46,15 +49,29 @@ 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);
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
async saveSettings() {
|
||||
const modelSettings = this.settingsForm.value;
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
if (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')?.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);
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<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 && (presence.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">(You)</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>
|
||||
|
@ -19,7 +19,7 @@
|
|||
<div>Last Active:
|
||||
<span *ngIf="member.lastActive == '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{member.lastActive | date: 'MM/dd/yyyy'}}
|
||||
{{member.lastActive | date: 'short'}}
|
||||
</ng-template>
|
||||
</div>
|
||||
<div *ngIf="!member.isAdmin">Sharing: {{formatLibraries(member)}}</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { User } from 'src/app/_models/user';
|
||||
|
@ -10,8 +10,8 @@ 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 { PresenceHubService } from 'src/app/_services/presence-hub.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
|
@ -34,7 +34,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
private modalService: NgbModal,
|
||||
private toastr: ToastrService,
|
||||
private confirmService: ConfirmService,
|
||||
public presence: PresenceHubService) {
|
||||
public messageHub: MessageHubService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
|
||||
this.loggedInUsername = user.username;
|
||||
});
|
||||
|
@ -77,7 +77,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
this.createMemberToggle = true;
|
||||
}
|
||||
|
||||
onMemberCreated(success: boolean) {
|
||||
onMemberCreated(createdUser: User | null) {
|
||||
this.createMemberToggle = false;
|
||||
this.loadMembers();
|
||||
}
|
||||
|
|
|
@ -35,4 +35,8 @@ export class SettingsService {
|
|||
getOpdsEnabled() {
|
||||
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
getAuthenticationEnabled() {
|
||||
return this.http.get<boolean>(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { HomeComponent } from './home/home.component';
|
||||
import { LibraryDetailComponent } from './library-detail/library-detail.component';
|
||||
import { LibraryComponent } from './library/library.component';
|
||||
import { NotConnectedComponent } from './not-connected/not-connected.component';
|
||||
import { SeriesDetailComponent } from './series-detail/series-detail.component';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
|
@ -10,13 +8,12 @@ import { UserLoginComponent } from './user-login/user-login.component';
|
|||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
||||
import { DashboardComponent as AdminDashboardComponent } from './admin/dashboard/dashboard.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
|
||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||
|
||||
const routes: Routes = [
|
||||
{path: '', component: HomeComponent},
|
||||
{path: '', component: UserLoginComponent},
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
|
||||
|
@ -62,7 +59,7 @@ const routes: Routes = [
|
|||
},
|
||||
{path: 'login', component: UserLoginComponent},
|
||||
{path: 'no-connection', component: NotConnectedComponent},
|
||||
{path: '**', component: HomeComponent, pathMatch: 'full'}
|
||||
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -5,7 +5,6 @@ import { AccountService } from './_services/account.service';
|
|||
import { LibraryService } from './_services/library.service';
|
||||
import { MessageHubService } from './_services/message-hub.service';
|
||||
import { NavService } from './_services/nav.service';
|
||||
import { PresenceHubService } from './_services/presence-hub.service';
|
||||
import { StatsService } from './_services/stats.service';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
@ -19,7 +18,7 @@ export class AppComponent implements OnInit {
|
|||
|
||||
constructor(private accountService: AccountService, public navService: NavService,
|
||||
private statsService: StatsService, private messageHub: MessageHubService,
|
||||
private presenceHub: PresenceHubService, private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) {
|
||||
private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) {
|
||||
|
||||
// Close any open modals when a route change occurs
|
||||
router.events
|
||||
|
@ -47,8 +46,7 @@ export class AppComponent implements OnInit {
|
|||
|
||||
if (user) {
|
||||
this.navService.setDarkMode(user.preferences.siteDarkMode);
|
||||
this.messageHub.createHubConnection(user);
|
||||
this.presenceHub.createHubConnection(user);
|
||||
this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user));
|
||||
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
|
||||
} else {
|
||||
this.navService.setDarkMode(true);
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { BrowserModule, Title } from '@angular/platform-browser';
|
||||
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { HomeComponent } from './home/home.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NavHeaderComponent } from './nav-header/nav-header.component';
|
||||
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
|
||||
import { UserLoginComponent } from './user-login/user-login.component';
|
||||
|
@ -22,72 +22,21 @@ import { AutocompleteLibModule } from 'angular-ng-autocomplete';
|
|||
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
|
||||
import { CarouselModule } from './carousel/carousel.module';
|
||||
|
||||
|
||||
import * as Sentry from '@sentry/angular';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { version } from 'package.json';
|
||||
import { Router } from '@angular/router';
|
||||
import { RewriteFrames as RewriteFramesIntegration } from '@sentry/integrations';
|
||||
import { Dedupe as DedupeIntegration } from '@sentry/integrations';
|
||||
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
||||
import { TypeaheadModule } from './typeahead/typeahead.module';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { CardsModule } from './cards/cards.module';
|
||||
import { CollectionsModule } from './collections/collections.module';
|
||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||
import { ReadingListModule } from './reading-list/reading-list.module';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||
import { ConfigData } from './_models/config-data';
|
||||
|
||||
let sentryProviders: any[] = [];
|
||||
|
||||
if (environment.production) {
|
||||
Sentry.init({
|
||||
dsn: 'https://db1a1f6445994b13a6f479512aecdd48@o641015.ingest.sentry.io/5757426',
|
||||
environment: environment.production ? 'prod' : 'dev',
|
||||
release: version,
|
||||
integrations: [
|
||||
new Sentry.Integrations.GlobalHandlers({
|
||||
onunhandledrejection: true,
|
||||
onerror: true
|
||||
}),
|
||||
new DedupeIntegration(),
|
||||
new RewriteFramesIntegration(),
|
||||
],
|
||||
ignoreErrors: [new RegExp(/\/api\/admin/)],
|
||||
tracesSampleRate: 0,
|
||||
});
|
||||
|
||||
Sentry.configureScope(scope => {
|
||||
scope.setUser({
|
||||
username: 'Not authorized'
|
||||
});
|
||||
scope.setTag('production', environment.production);
|
||||
scope.setTag('version', version);
|
||||
});
|
||||
|
||||
sentryProviders = [{
|
||||
provide: ErrorHandler,
|
||||
useValue: Sentry.createErrorHandler({
|
||||
showDialog: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: Sentry.TraceService,
|
||||
deps: [Router],
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: () => () => {},
|
||||
deps: [Sentry.TraceService],
|
||||
multi: true,
|
||||
}];
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
HomeComponent,
|
||||
NavHeaderComponent,
|
||||
UserLoginComponent,
|
||||
LibraryComponent,
|
||||
|
@ -114,6 +63,8 @@ if (environment.production) {
|
|||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
|
||||
NgbCollapseModule, // Login
|
||||
|
||||
SharedModule,
|
||||
CarouselModule,
|
||||
TypeaheadModule,
|
||||
|
@ -134,7 +85,7 @@ if (environment.production) {
|
|||
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
|
||||
Title,
|
||||
{provide: SAVER, useFactory: getSaver},
|
||||
...sentryProviders,
|
||||
{ provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] },
|
||||
],
|
||||
entryComponents: [],
|
||||
bootstrap: [AppComponent]
|
||||
|
|
|
@ -58,17 +58,16 @@
|
|||
<button (click)="toggleClickToPaginate()" class="btn btn-icon" aria-labelledby="tap-pagination"><i class="fa fa-arrows-alt-h {{clickToPaginate ? 'icon-primary-color' : ''}}" aria-hidden="true"></i><span *ngIf="darkMode"> {{clickToPaginate ? 'On' : 'Off'}}</span></button>
|
||||
</div>
|
||||
<div class="row no-gutters justify-content-between">
|
||||
<button (click)="resetSettings()" class="btn btn-secondary col">Reset</button>
|
||||
<button (click)="saveSettings()" class="btn btn-primary col" style="margin-left:10px;">Save</button>
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||
<div class="col-1" style="margin-top: 6px">{{pageNum}}</div>
|
||||
<div class="col-8" style="margin-top: 15px">
|
||||
<div class="col-1 page-stub">{{pageNum}}</div>
|
||||
<div class="col-8" style="margin-top: 15px;padding-right:10px">
|
||||
<ngb-progressbar style="cursor: pointer" title="Go to page" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="col-1 btn-icon" style="margin-top: 6px" (click)="goToPage(maxPages - 1)" title="Go to last page">{{maxPages - 1}}</div>
|
||||
<div class="col-1 btn-icon page-stub" (click)="goToPage(maxPages - 1)" title="Go to last page">{{maxPages - 1}}</div>
|
||||
<button class="btn btn-small btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter()" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="table-of-contents">
|
||||
|
@ -100,24 +99,15 @@
|
|||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<!-- This pushes down the page. Need to overlay
|
||||
<ng-container *ngIf="isLoading">
|
||||
<div class="d-flex justify-content-center m-5">
|
||||
<div class="spinner-border text-secondary loading" role="status">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container> -->
|
||||
|
||||
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
||||
<div #readingHtml [innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px'}" [innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
<div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
|
||||
<div [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" *ngIf="page !== undefined && scrollbarNeeded">
|
||||
<div *ngIf="page !== undefined && scrollbarNeeded">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -138,7 +128,7 @@
|
|||
[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 ? pageNum + 1 > maxPages - 1 : pageNum === 0) ? 'fa-angle-double-right' : 'fa-angle-right'}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype");
|
||||
}
|
||||
|
||||
$dark-form-background-no-opacity: rgb(1, 4, 9);
|
||||
$primary-color: #0062cc;
|
||||
|
||||
.control-container {
|
||||
|
@ -42,6 +43,15 @@ $primary-color: #0062cc;
|
|||
}
|
||||
}
|
||||
|
||||
.page-stub {
|
||||
margin-top: 6px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.fixed-top {
|
||||
z-index: 1022;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
|
||||
|
@ -73,60 +83,12 @@ $primary-color: #0062cc;
|
|||
color: #8db2e5 !important;
|
||||
}
|
||||
|
||||
// Coppied
|
||||
// html, body {
|
||||
// color: #dcdcdc !important;
|
||||
// background-image: none !important;
|
||||
// background-color: #292929 !important;
|
||||
// }
|
||||
|
||||
// html::before, body::before {
|
||||
// background-image: none !important;
|
||||
// }
|
||||
|
||||
// html *:not(input) {color: #dcdcdc !important}
|
||||
// html * {background-color: rgb(41, 41, 41, 0.90) !important}
|
||||
|
||||
// html *, html *[id], html *[class] {
|
||||
// box-shadow: none !important;
|
||||
// text-shadow: none !important;
|
||||
// border-radius: unset !important;
|
||||
// border-color: #555555 !important;
|
||||
// outline-color: #555555 !important;
|
||||
// }
|
||||
|
||||
img, img[src] {
|
||||
img, img[src] {
|
||||
z-index: 1;
|
||||
filter: brightness(0.85) !important;
|
||||
background-color: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
// video, video[src] {
|
||||
// z-index: 1;
|
||||
// background-color: transparent !important;
|
||||
// }
|
||||
|
||||
// input:not([type='button']):not([type='submit']) {
|
||||
// color: #dcdcdc !important;
|
||||
// background-image: none !important;
|
||||
// background-color: #333333 !important;
|
||||
// }
|
||||
|
||||
// textarea, textarea[class], input[type='text'], input[type='text'][class] {
|
||||
// color: #dcdcdc !important;
|
||||
// background-color: #555555 !important;
|
||||
// }
|
||||
|
||||
// svg:not([fill]) {fill: #7d7d7d !important}
|
||||
// li, select {background-image: none !important}
|
||||
// input[type='text'], input[type='search'] {text-indent: 10px}
|
||||
// a {background-color: rgba(255, 255, 255, 0.01) !important}
|
||||
// html cite, html cite *, html cite *[class] {color: #029833 !important}
|
||||
// svg[fill], button, input[type='button'], input[type='submit'] {opacity: 0.85 !important}
|
||||
|
||||
// :before {color: #dcdcdc !important}
|
||||
// :link:not(cite), :link *:not(cite) {color: #8db2e5 !important}
|
||||
// :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important}
|
||||
:visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important}
|
||||
:link:not(cite), :link *:not(cite) {color: #8db2e5 !important}
|
||||
}
|
||||
|
@ -134,6 +96,48 @@ $primary-color: #0062cc;
|
|||
.reading-bar {
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%);
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
.reading-bar, .book-title, .drawer-body, .drawer-container {
|
||||
background-color: $dark-form-background-no-opacity;
|
||||
}
|
||||
button {
|
||||
background-color: $dark-form-background-no-opacity;
|
||||
}
|
||||
|
||||
.btn {
|
||||
&.btn-secondary {
|
||||
border-color: transparent;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #545b62;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline-secondary {
|
||||
border-color: transparent;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #545b62;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
i {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .dark-mode .drawer-container {
|
||||
.header, body, *:not(.progress-bar) {
|
||||
background-color: $dark-form-background-no-opacity !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 875px) {
|
||||
|
@ -200,4 +204,41 @@ $primary-color: #0062cc;
|
|||
.highlight-2 {
|
||||
background-color: rgba(65, 105, 225, 0.5) !important;
|
||||
animation: fadein .5s both;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
&.btn-secondary {
|
||||
color: #6c757d;
|
||||
border-color: transparent;
|
||||
background-color: unset;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #545b62;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline-secondary {
|
||||
border-color: transparent;
|
||||
background-color: unset;
|
||||
|
||||
&:hover, &:focus {
|
||||
border-color: #545b62;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: unset;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
i {
|
||||
background-color: unset;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
&:active {
|
||||
* {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,15 +14,16 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
|
||||
import { BookService } from '../book.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { BookChapterItem } from '../_models/book-chapter-item';
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
import { Stack } from 'src/app/shared/data-structures/stack';
|
||||
import { Preferences } from 'src/app/_models/preferences/preferences';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
|
||||
import { ScrollService } from 'src/app/scroll.service';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
|
||||
|
||||
interface PageStyle {
|
||||
|
@ -166,11 +167,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
pageAnchors: {[n: string]: number } = {};
|
||||
currentPageAnchor: string = '';
|
||||
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [1] });
|
||||
/**
|
||||
* Last seen progress part path
|
||||
*/
|
||||
lastSeenScrollPartPath: string = '';
|
||||
/**
|
||||
* Library Type used for rendering chapter or issue
|
||||
*/
|
||||
libraryType: LibraryType = LibraryType.Book;
|
||||
|
||||
/**
|
||||
* Hack: Override background color for reader and restore it onDestroy
|
||||
|
@ -186,10 +190,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
color: #e83e8c !important;
|
||||
}
|
||||
|
||||
// .btn-icon {
|
||||
// background-color: transparent;
|
||||
// }
|
||||
|
||||
:link, a {
|
||||
color: #8db2e5 !important;
|
||||
}
|
||||
|
@ -205,25 +205,31 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
return ReadingDirection;
|
||||
}
|
||||
|
||||
get IsPrevDisabled() {
|
||||
get IsPrevDisabled(): boolean {
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
// Acting as Previous button
|
||||
return this.prevPageDisabled && this.pageNum === 0;
|
||||
}
|
||||
return this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1;
|
||||
} else {
|
||||
// Acting as a Next button
|
||||
return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1;
|
||||
}
|
||||
}
|
||||
|
||||
get IsNextDisabled() {
|
||||
get IsNextDisabled(): boolean {
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1;
|
||||
// Acting as Next button
|
||||
return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1;
|
||||
} else {
|
||||
// Acting as Previous button
|
||||
return this.prevPageDisabled && this.pageNum === 0;
|
||||
}
|
||||
return this.prevPageDisabled && this.pageNum === 0;
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
|
||||
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
|
||||
private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService,
|
||||
private scrollService: ScrollService) {
|
||||
private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService) {
|
||||
this.navService.hideNavBar();
|
||||
|
||||
this.darkModeStyleElem = this.renderer.createElement('style');
|
||||
|
@ -279,6 +285,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
fromEvent(window, 'scroll')
|
||||
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (this.isLoading) return;
|
||||
|
||||
// Highlight the current chapter we are on
|
||||
if (Object.keys(this.pageAnchors).length !== 0) {
|
||||
// get the height of the document so we can capture markers that are halfway on the document viewport
|
||||
const verticalOffset = this.scrollService.scrollPosition + (document.body.offsetHeight / 2);
|
||||
|
@ -286,16 +294,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
||||
if (alreadyReached.length > 0) {
|
||||
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
|
||||
|
||||
if (!this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this.currentPageAnchor = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Find the element that is on screen to bookmark against
|
||||
const intersectingEntries = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span'))
|
||||
.filter(element => !element.classList.contains('no-observe'))
|
||||
.filter(entry => {
|
||||
return this.utilityService.isInViewport(entry, this.topOffset);
|
||||
});
|
||||
|
||||
intersectingEntries.sort((a: Element, b: Element) => {
|
||||
const aTop = a.getBoundingClientRect().top;
|
||||
const bTop = b.getBoundingClientRect().top;
|
||||
if (aTop < bTop) {
|
||||
return -1;
|
||||
}
|
||||
if (aTop > bTop) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
let path = this.getXPathTo(intersectingEntries[0]);
|
||||
if (path === '') { return; }
|
||||
if (!path.startsWith('id')) {
|
||||
path = '//html[1]/' + path;
|
||||
}
|
||||
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 */});
|
||||
}
|
||||
|
@ -326,7 +359,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -392,6 +424,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
|
||||
this.continuousChaptersStack.push(this.chapterId);
|
||||
|
||||
this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (this.pageNum >= this.maxPages) {
|
||||
|
@ -443,12 +480,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
let intersectingEntries = Array.from(entries)
|
||||
.filter(entry => entry.isIntersecting)
|
||||
.map(entry => entry.target)
|
||||
intersectingEntries.sort((a: Element, b: Element) => {
|
||||
const aTop = a.getBoundingClientRect().top;
|
||||
sortElements(a: Element, b: Element) {
|
||||
const aTop = a.getBoundingClientRect().top;
|
||||
const bTop = b.getBoundingClientRect().top;
|
||||
if (aTop < bTop) {
|
||||
return -1;
|
||||
|
@ -458,17 +491,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
let path = this.getXPathTo(intersectingEntries[0]);
|
||||
if (path === '') { return; }
|
||||
if (!path.startsWith('id')) {
|
||||
path = '//html[1]/' + path;
|
||||
}
|
||||
this.lastSeenScrollPartPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
loadNextChapter() {
|
||||
|
@ -515,10 +537,10 @@ 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.init();
|
||||
this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000});
|
||||
this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000});
|
||||
} else {
|
||||
// This will only happen if no actual chapter can be found
|
||||
this.toastr.warning('Could not find ' + direction + ' chapter');
|
||||
this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase());
|
||||
this.isLoading = false;
|
||||
if (direction === 'Prev') {
|
||||
this.prevPageDisabled = true;
|
||||
|
@ -555,9 +577,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
margin = this.user.preferences.bookReaderMargin + '%';
|
||||
}
|
||||
this.pageStyles = {'font-family': this.user.preferences.bookReaderFontFamily, 'font-size': this.user.preferences.bookReaderFontSize + '%', 'margin-left': margin, 'margin-right': margin, 'line-height': this.user.preferences.bookReaderLineSpacing + '%'};
|
||||
if (this.user.preferences.siteDarkMode && !this.user.preferences.bookReaderDarkMode) {
|
||||
this.user.preferences.bookReaderDarkMode = true;
|
||||
}
|
||||
|
||||
this.toggleDarkMode(this.user.preferences.bookReaderDarkMode);
|
||||
} else {
|
||||
this.pageStyles = {'font-family': 'default', 'font-size': '100%', 'margin-left': margin, 'margin-right': margin, 'line-height': '100%'};
|
||||
|
@ -651,12 +671,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
setupPageAnchors() {
|
||||
this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => {
|
||||
if (!elem.classList.contains('no-observe')) {
|
||||
this.intersectionObserver.observe(elem);
|
||||
}
|
||||
});
|
||||
|
||||
this.pageAnchors = {};
|
||||
this.currentPageAnchor = '';
|
||||
const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0);
|
||||
|
@ -869,7 +883,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
getDarkModeBackgroundColor() {
|
||||
return this.darkMode ? '#292929' : '#fff';
|
||||
return this.darkMode ? '#010409' : '#fff';
|
||||
}
|
||||
|
||||
setOverrideStyles() {
|
||||
|
@ -890,33 +904,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
if (this.user === undefined) return;
|
||||
const modelSettings = this.settingsForm.value;
|
||||
const data: Preferences = {
|
||||
readingDirection: this.user.preferences.readingDirection,
|
||||
scalingOption: this.user.preferences.scalingOption,
|
||||
pageSplitOption: this.user.preferences.pageSplitOption,
|
||||
autoCloseMenu: this.user.preferences.autoCloseMenu,
|
||||
readerMode: this.user.preferences.readerMode,
|
||||
bookReaderDarkMode: this.darkMode,
|
||||
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
|
||||
bookReaderFontSize: parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10),
|
||||
bookReaderLineSpacing: parseInt(this.pageStyles['line-height'].replace('!important', '').trim(), 10),
|
||||
bookReaderMargin: parseInt(this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim(), 10),
|
||||
bookReaderTapToPaginate: this.clickToPaginate,
|
||||
bookReaderReadingDirection: this.readingDirection,
|
||||
siteDarkMode: this.user.preferences.siteDarkMode,
|
||||
};
|
||||
this.accountService.updatePreferences(data).pipe(take(1)).subscribe((updatedPrefs) => {
|
||||
this.toastr.success('User settings updated');
|
||||
if (this.user) {
|
||||
this.user.preferences = updatedPrefs;
|
||||
}
|
||||
this.resetSettings();
|
||||
});
|
||||
}
|
||||
|
||||
toggleDrawer() {
|
||||
this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight;
|
||||
this.drawerOpen = !this.drawerOpen;
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
<div *ngIf="data !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">
|
||||
<h4 *ngIf="libraryType !== LibraryType.Comic else comicHeader" class="modal-title" id="modal-basic-title">
|
||||
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}} Details</h4>
|
||||
<ng-template #comicHeader><h4 class="modal-title" id="modal-basic-title">
|
||||
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}} Details</h4>
|
||||
</ng-template>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
|
@ -19,7 +22,7 @@
|
|||
</div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col" *ngIf="utilityService.isVolume(data)">
|
||||
Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}}
|
||||
Added: {{(data.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{data.pages}}
|
||||
|
@ -27,18 +30,19 @@
|
|||
</div>
|
||||
</ng-container>
|
||||
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">Chapters</h4>
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li class="media my-4" *ngFor="let chapter of chapters">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read Chapter {{chapter.number}}">
|
||||
<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">
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<span class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="'Chapter' + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
</span>Chapter {{formatChapterNumber(chapter)}}
|
||||
<span >
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<span class="badge badge-primary badge-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
|
|
|
@ -13,6 +13,8 @@ import { ActionService } from 'src/app/_services/action.service';
|
|||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
|
||||
import { LibraryType } from '../../../_models/library';
|
||||
import { LibraryService } from '../../../_services/library.service';
|
||||
|
||||
|
||||
|
||||
|
@ -39,12 +41,16 @@ export class CardDetailsModalComponent implements OnInit {
|
|||
isAdmin: boolean = false;
|
||||
actions: ActionItem<any>[] = [];
|
||||
chapterActions: ActionItem<Chapter>[] = [];
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
|
||||
get LibraryType(): typeof LibraryType {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
||||
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, private router: Router) { }
|
||||
private actionService: ActionService, private router: Router, private libraryService: LibraryService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.data);
|
||||
|
@ -55,6 +61,10 @@ export class CardDetailsModalComponent implements OnInit {
|
|||
}
|
||||
});
|
||||
|
||||
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
});
|
||||
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
|
||||
|
||||
if (this.isChapter) {
|
||||
|
@ -94,7 +104,7 @@ export class CardDetailsModalComponent implements OnInit {
|
|||
const chapter = this.utilityService.asChapter(this.data)
|
||||
chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id);
|
||||
modalRef.componentInstance.chapter = chapter;
|
||||
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover';
|
||||
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : this.utilityService.formatChapterName(this.libraryType, false, true)) + chapter.range + '\'s Cover';
|
||||
} else {
|
||||
const volume = this.utilityService.asVolume(this.data);
|
||||
const chapters = volume.chapters;
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
<ng-template ngbNavContent>
|
||||
<h4>Information</h4>
|
||||
<div class="row no-gutters mb-2">
|
||||
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | titlecase}}</div>
|
||||
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | sentenceCase}}</div>
|
||||
<div class="col-md-6">Format: <app-tag-badge>{{utilityService.mangaFormat(series.format)}}</app-tag-badge></div>
|
||||
</div>
|
||||
<h4>Volumes</h4>
|
||||
|
@ -110,10 +110,10 @@
|
|||
<div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Created: {{volume.created | date: 'MM/dd/yyyy'}}
|
||||
Created: {{volume.created | date: 'short'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Last Modified: {{volume.lastModified | date: 'MM/dd/yyyy'}}
|
||||
Last Modified: {{volume.lastModified | date: 'short'}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
|
|
|
@ -38,6 +38,6 @@
|
|||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
</span>
|
||||
</div>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!supressLibraryLink && libraryName">{{libraryName | titlecase}}</a>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!supressLibraryLink && libraryName">{{libraryName | sentenceCase}}</a>
|
||||
</div>
|
||||
</div>
|
|
@ -120,14 +120,33 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
|
||||
|
||||
prevTouchTime: number = 0;
|
||||
prevOffset: number = 0;
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
const verticalOffset = (window.pageYOffset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
|
||||
this.prevTouchTime = event.timeStamp;
|
||||
this.prevOffset = verticalOffset;
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(event: TouchEvent) {
|
||||
if (event.timeStamp - this.prevTouchTime >= 200) {
|
||||
if (!this.allowSelection) return;
|
||||
const delta = event.timeStamp - this.prevTouchTime;
|
||||
const verticalOffset = (window.pageYOffset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
|
||||
if (verticalOffset != this.prevOffset) {
|
||||
this.prevTouchTime = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (delta >= 300 && delta <= 1000) {
|
||||
this.handleSelection();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
|
@ -11,13 +11,16 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { RefreshMetadataEvent } from 'src/app/_models/events/refresh-metadata-event';
|
||||
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-card',
|
||||
templateUrl: './series-card.component.html',
|
||||
styleUrls: ['./series-card.component.scss']
|
||||
})
|
||||
export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() data!: Series;
|
||||
@Input() libraryId = 0;
|
||||
@Input() suppressLibraryLink = false;
|
||||
|
@ -41,12 +44,13 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
isAdmin = false;
|
||||
actions: ActionItem<Series>[] = [];
|
||||
imageUrl: string = '';
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router,
|
||||
private seriesService: SeriesService, private toastr: ToastrService,
|
||||
private modalService: NgbModal, private confirmService: ConfirmService,
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService) {
|
||||
private actionService: ActionService, private hubService: MessageHubService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
|
@ -58,6 +62,12 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,6 +78,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleSeriesActionCallback(action: Action, series: Series) {
|
||||
switch (action) {
|
||||
case(Action.MarkAsRead):
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { Component, HostListener, OnInit } from '@angular/core';
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
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 { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
|
@ -17,6 +19,7 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
|
|||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
@Component({
|
||||
|
@ -24,7 +27,7 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||
templateUrl: './collection-detail.component.html',
|
||||
styleUrls: ['./collection-detail.component.scss']
|
||||
})
|
||||
export class CollectionDetailComponent implements OnInit {
|
||||
export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
collectionTag!: CollectionTag;
|
||||
tagImage: string = '';
|
||||
|
@ -40,6 +43,8 @@ export class CollectionDetailComponent implements OnInit {
|
|||
mangaFormat: null
|
||||
};
|
||||
|
||||
private onDestory: Subject<void> = new Subject<void>();
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
|
@ -68,7 +73,7 @@ export class CollectionDetailComponent implements OnInit {
|
|||
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
|
||||
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
|
||||
private modalService: NgbModal, private titleService: Title, private accountService: AccountService,
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService) {
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
|
@ -88,6 +93,18 @@ export class CollectionDetailComponent implements OnInit {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
||||
|
||||
this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => {
|
||||
const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
|
||||
if (collectionEvent.tagId === this.collectionTag.id) {
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
<div class="container">
|
||||
<ng-container *ngIf="firstTimeFlow">
|
||||
<p>Please create an admin account for yourself to start your reading journey.</p>
|
||||
<app-register-member (created)="onAdminCreated($event)" [firstTimeFlow]="firstTimeFlow"></app-register-member>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { MemberService } from '../_services/member.service';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
|
||||
firstTimeFlow = false;
|
||||
model: any = {};
|
||||
registerForm: FormGroup = new FormGroup({
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', [Validators.required])
|
||||
});
|
||||
|
||||
constructor(public accountService: AccountService, private memberService: MemberService, private router: Router, private titleService: Title) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.memberService.adminExists().subscribe(adminExists => {
|
||||
this.firstTimeFlow = !adminExists;
|
||||
|
||||
if (this.firstTimeFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.titleService.setTitle('Kavita');
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.router.navigateByUrl('/library');
|
||||
} else {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
onAdminCreated(success: boolean) {
|
||||
if (success) {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import { Component, HostListener, OnInit } from '@angular/core';
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } 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';
|
||||
|
@ -12,6 +14,7 @@ import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-
|
|||
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { MessageHubService } from '../_services/message-hub.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
@Component({
|
||||
|
@ -19,7 +22,7 @@ import { SeriesService } from '../_services/series.service';
|
|||
templateUrl: './library-detail.component.html',
|
||||
styleUrls: ['./library-detail.component.scss']
|
||||
})
|
||||
export class LibraryDetailComponent implements OnInit {
|
||||
export class LibraryDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
libraryId!: number;
|
||||
libraryName = '';
|
||||
|
@ -31,6 +34,7 @@ export class LibraryDetailComponent implements OnInit {
|
|||
filter: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
};
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
@ -60,7 +64,7 @@ export class LibraryDetailComponent implements OnInit {
|
|||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
|
||||
const routeId = this.route.snapshot.paramMap.get('id');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
|
@ -78,7 +82,14 @@ export class LibraryDetailComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.hubService.seriesAdded.pipe(takeWhile(event => event.libraryId === this.libraryId), debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => {
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
|
|
|
@ -205,7 +205,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
return document.documentElement.offsetHeight + document.documentElement.scrollTop;
|
||||
}
|
||||
getScrollTop() {
|
||||
return document.documentElement.scrollTop
|
||||
return document.documentElement.scrollTop;
|
||||
}
|
||||
|
||||
checkIfShouldTriggerContinuousReader() {
|
||||
|
|
|
@ -31,8 +31,8 @@
|
|||
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller>
|
||||
</div>
|
||||
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'bottom'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')"></div>
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')"></div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option';
|
|||
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
||||
import { forkJoin, ReplaySubject, Subject } from 'rxjs';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { CircularArray } from '../shared/data-structures/circular-array';
|
||||
import { MemberService } from '../_services/member.service';
|
||||
import { Stack } from '../shared/data-structures/stack';
|
||||
|
@ -23,6 +23,8 @@ import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from
|
|||
import { Preferences, scalingOptions } from '../_models/preferences/preferences';
|
||||
import { READER_MODE } from '../_models/preferences/reader-mode';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { LibraryType } from '../_models/library';
|
||||
|
||||
const PREFETCH_PAGES = 5;
|
||||
|
||||
|
@ -201,6 +203,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
* A map of bookmarked pages to anything. Used for O(1) lookup time if a page is bookmarked or not.
|
||||
*/
|
||||
bookmarks: {[key: string]: number} = {};
|
||||
/**
|
||||
* Tracks if the first page is rendered or not. This is used to keep track of Automatic Scaling and adjusting decision after first page dimensions load up.
|
||||
*/
|
||||
firstPageRendered: boolean = false;
|
||||
/**
|
||||
* Library Type used for rendering chapter or issue
|
||||
*/
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
@ -256,7 +266,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||
public readerService: ReaderService, private location: Location,
|
||||
private formBuilder: FormBuilder, private navService: NavService,
|
||||
private toastr: ToastrService, private memberService: MemberService) {
|
||||
private toastr: ToastrService, private memberService: MemberService,
|
||||
private libraryService: LibraryService, private utilityService: UtilityService) {
|
||||
this.navService.hideNavBar();
|
||||
}
|
||||
|
||||
|
@ -321,7 +332,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
});
|
||||
} else {
|
||||
// If no user, we can't render
|
||||
this.router.navigateByUrl('/home');
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -396,7 +407,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
forkJoin({
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
chapterInfo: this.readerService.getChapterInfo(this.chapterId),
|
||||
bookmarks: this.readerService.getBookmarks(this.chapterId)
|
||||
bookmarks: this.readerService.getBookmarks(this.chapterId),
|
||||
}).pipe(take(1)).subscribe(results => {
|
||||
|
||||
if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) {
|
||||
|
@ -421,7 +432,12 @@ 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;
|
||||
|
||||
this.updateTitle(results.chapterInfo);
|
||||
this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
this.updateTitle(results.chapterInfo, type);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// From bookmarks, create map of pages to make lookup time O(1)
|
||||
this.bookmarks = {};
|
||||
|
@ -475,7 +491,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
updateTitle(chapterInfo: ChapterInfo) {
|
||||
updateTitle(chapterInfo: ChapterInfo, type: LibraryType) {
|
||||
this.title = chapterInfo.seriesName;
|
||||
if (chapterInfo.chapterTitle.length > 0) {
|
||||
this.title += ' - ' + chapterInfo.chapterTitle;
|
||||
|
@ -485,12 +501,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') {
|
||||
this.subtitle = chapterInfo.fileName;
|
||||
} else if (!chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') {
|
||||
this.subtitle = 'Chapter ' + chapterInfo.chapterNumber;
|
||||
this.subtitle = this.utilityService.formatChapterName(type, true, true) + chapterInfo.chapterNumber;
|
||||
} else {
|
||||
this.subtitle = 'Volume ' + chapterInfo.volumeNumber;
|
||||
|
||||
if (chapterInfo.chapterNumber !== '0') {
|
||||
this.subtitle += ' Chapter ' + chapterInfo.chapterNumber;
|
||||
this.subtitle += ' ' + this.utilityService.formatChapterName(type, true, true) + chapterInfo.chapterNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -760,10 +776,10 @@ export class MangaReaderComponent 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.init();
|
||||
this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000});
|
||||
this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000});
|
||||
} else {
|
||||
// This will only happen if no actual chapter can be found
|
||||
this.toastr.warning('Could not find ' + direction.toLowerCase() + ' chapter');
|
||||
this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase());
|
||||
this.isLoading = false;
|
||||
if (direction === 'Prev') {
|
||||
this.prevPageDisabled = true;
|
||||
|
@ -822,6 +838,30 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
|
||||
} else {
|
||||
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
|
||||
|
||||
let newScale = this.generalSettingsForm.get('fittingOption')?.value;
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
|
||||
const widthRatio = windowWidth / this.canvasImage.width;
|
||||
const heightRatio = windowHeight / this.canvasImage.height;
|
||||
|
||||
// Given that we now have image dimensions, assuming this isn't a split image,
|
||||
// Try to reset one time based on who's dimension (width/height) is smaller
|
||||
if (widthRatio < heightRatio) {
|
||||
newScale = FITTING_OPTION.WIDTH;
|
||||
} else if (widthRatio > heightRatio) {
|
||||
newScale = FITTING_OPTION.HEIGHT;
|
||||
}
|
||||
|
||||
this.generalSettingsForm.get('fittingOption')?.setValue(newScale);
|
||||
this.firstPageRendered = true;
|
||||
}
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||
}
|
||||
// Reset scroll on non HEIGHT Fits
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
|
||||
<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 | titlecase}}
|
||||
{{user.username | sentenceCase}}
|
||||
</button>
|
||||
<div ngbDropdownMenu>
|
||||
<a ngbDropdownItem routerLink="/preferences/">User Settings</a>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div class="badge">
|
||||
<!-- TODO: Put a person image container here -->
|
||||
<!-- Put a person image container here -->
|
||||
<div class="img">
|
||||
|
||||
</div>
|
||||
|
@ -9,4 +9,4 @@
|
|||
<ng-content select="[role]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { take } from 'rxjs/operators';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { ReadingList, ReadingListItem } from 'src/app/_models/reading-list';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
@ -12,6 +13,8 @@ import { ActionService } from 'src/app/_services/action.service';
|
|||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component';
|
||||
import { LibraryService } from '../../_services/library.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-list-detail',
|
||||
|
@ -19,7 +22,6 @@ import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/drag
|
|||
styleUrls: ['./reading-list-detail.component.scss']
|
||||
})
|
||||
export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
items: Array<ReadingListItem> = [];
|
||||
listId!: number;
|
||||
readingList!: ReadingList;
|
||||
|
@ -32,6 +34,7 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
hasDownloadingRole: boolean = false;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
libraryTypes: {[key: number]: LibraryType} = {};
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
|
@ -39,7 +42,8 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService,
|
||||
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService, private confirmService: ConfirmService) {}
|
||||
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const listId = this.route.snapshot.paramMap.get('id');
|
||||
|
@ -51,7 +55,21 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
|
||||
this.listId = parseInt(listId, 10);
|
||||
|
||||
this.readingListService.getReadingList(this.listId).subscribe(readingList => {
|
||||
this.libraryService.getLibraries().subscribe(libs => {
|
||||
|
||||
});
|
||||
|
||||
forkJoin([
|
||||
this.libraryService.getLibraries(),
|
||||
this.readingListService.getReadingList(this.listId)
|
||||
]).subscribe(results => {
|
||||
const libraries = results[0];
|
||||
const readingList = results[1];
|
||||
|
||||
libraries.forEach(lib => {
|
||||
this.libraryTypes[lib.id] = lib.type;
|
||||
});
|
||||
|
||||
if (readingList == null) {
|
||||
// The list doesn't exist
|
||||
this.toastr.error('This list doesn\'t exist.');
|
||||
|
@ -81,7 +99,6 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
// TODO: Try to move performAction into the actionables component. (have default handler in the component, allow for overridding to pass additional context)
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, this.readingList);
|
||||
}
|
||||
|
@ -119,7 +136,7 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber);
|
||||
}
|
||||
|
||||
return 'Chapter ' + item.chapterNumber;
|
||||
return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + item.chapterNumber;
|
||||
}
|
||||
|
||||
orderUpdated(event: IndexUpdateEvent) {
|
||||
|
|
|
@ -41,7 +41,6 @@ export class ReadingListsComponent implements OnInit {
|
|||
}
|
||||
|
||||
performAction(action: ActionItem<any>, readingList: ReadingList) {
|
||||
// TODO: Try to move performAction into the actionables component. (have default handler in the component, allow for overridding to pass additional context)
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, readingList);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
<div class="text-danger" *ngIf="errors.length > 0">
|
||||
<p>Errors:</p>
|
||||
<ul>
|
||||
|
@ -10,7 +11,7 @@
|
|||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group" *ngIf="registerForm.get('isAdmin')?.value || !authDisabled">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" class="form-control" formControlName="password" type="password">
|
||||
</div>
|
||||
|
@ -21,7 +22,7 @@
|
|||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-secondary mr-2" type="button" (click)="cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" type="submit">Register</button>
|
||||
<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>
|
||||
</form>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
.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;
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
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',
|
||||
|
@ -10,35 +13,42 @@ import { AccountService } from 'src/app/_services/account.service';
|
|||
export class RegisterMemberComponent implements OnInit {
|
||||
|
||||
@Input() firstTimeFlow = false;
|
||||
@Output() created = new EventEmitter<boolean>();
|
||||
/**
|
||||
* 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('', [Validators.required]),
|
||||
password: new FormControl('', []),
|
||||
isAdmin: new FormControl(false, [])
|
||||
});
|
||||
errors: string[] = [];
|
||||
|
||||
constructor(private accountService: AccountService) {
|
||||
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(resp => {
|
||||
this.created.emit(true);
|
||||
this.accountService.register(this.registerForm.value).subscribe(user => {
|
||||
this.created.emit(user);
|
||||
}, err => {
|
||||
this.errors = err;
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.created.emit(false);
|
||||
this.created.emit(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -112,7 +112,7 @@
|
|||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="2" *ngIf="hasNonSpecialVolumeChapters">
|
||||
<a ngbNavLink>Volumes/Chapters</a>
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row no-gutters">
|
||||
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
|
@ -121,7 +121,7 @@
|
|||
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
<div *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="utilityService.formatChapterName(libraryType, true, true) + chapter.range" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
|
|
|
@ -259,7 +259,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
break;
|
||||
case(Action.IncognitoRead):
|
||||
if (volume.chapters != undefined && volume.chapters?.length >= 1) {
|
||||
this.openChapter(volume.chapters[0], true);
|
||||
this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
|
@ -56,6 +57,27 @@ export class UtilityService {
|
|||
return this.mangaFormatKeys.filter(item => MangaFormat[format] === item)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Chapter name based on the library it's in
|
||||
* @param libraryType
|
||||
* @param includeHash For comics only, includes a # which is used for numbering on cards
|
||||
* @param includeSpace Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end.
|
||||
* @returns
|
||||
*/
|
||||
formatChapterName(libraryType: LibraryType, includeHash: boolean = false, includeSpace: boolean = false) {
|
||||
switch(libraryType) {
|
||||
case LibraryType.Book:
|
||||
return 'Book' + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Comic:
|
||||
if (includeHash) {
|
||||
return 'Issue #';
|
||||
}
|
||||
return 'Issue' + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Manga:
|
||||
return 'Chapter' + (includeSpace ? ' ' : '');
|
||||
}
|
||||
}
|
||||
|
||||
cleanSpecialTitle(title: string) {
|
||||
let cleaned = title.replace(/_/g, ' ').replace(/SP\d+/g, '').trim();
|
||||
cleaned = cleaned.substring(0, cleaned.lastIndexOf('.'));
|
||||
|
@ -127,4 +149,14 @@ export class UtilityService {
|
|||
return Breakpoint.Desktop;
|
||||
}
|
||||
|
||||
isInViewport(element: Element, additionalTopOffset: number = 0) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= additionalTopOffset &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
height: 100vh;
|
||||
background: var(--drawer-background-color);
|
||||
transition: all 300ms;
|
||||
box-shadow: -3px 0px 6px 1px #00000026;
|
||||
box-shadow: 0 6px 4px 2px rgb(0 0 0 / 70%);
|
||||
padding: 10px 10px;
|
||||
z-index: 1021;
|
||||
overflow: auto;
|
||||
|
|
14
UI/Web/src/app/shared/sentence-case.pipe.ts
Normal file
14
UI/Web/src/app/shared/sentence-case.pipe.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'sentenceCase'
|
||||
})
|
||||
export class SentenceCasePipe implements PipeTransform {
|
||||
|
||||
transform(value: string | null): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
return value.charAt(0).toUpperCase() + value.substr(1);
|
||||
}
|
||||
|
||||
}
|
|
@ -15,6 +15,7 @@ import { SeriesFormatComponent } from './series-format/series-format.component';
|
|||
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
|
||||
import { CircularLoaderComponent } from './circular-loader/circular-loader.component';
|
||||
import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||
import { SentenceCasePipe } from './sentence-case.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -29,6 +30,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
|
|||
SeriesFormatComponent,
|
||||
UpdateNotificationModalComponent,
|
||||
CircularLoaderComponent,
|
||||
SentenceCasePipe,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -40,6 +42,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
|
|||
exports: [
|
||||
RegisterMemberComponent,
|
||||
SafeHtmlPipe,
|
||||
SentenceCasePipe,
|
||||
ReadMoreComponent,
|
||||
DrawerComponent,
|
||||
TagBadgeComponent,
|
||||
|
|
|
@ -1,28 +1,46 @@
|
|||
<div class="mx-auto login">
|
||||
<div class="card p-3" style="width: 18rem;">
|
||||
<div class="logo-container">
|
||||
<img class="logo" src="assets/images/kavita-book-cropped.png" alt="Kavita logo"/>
|
||||
<h3 class="card-title text-center">Kavita</h3>
|
||||
|
||||
<ng-container *ngIf="isLoaded">
|
||||
<div class="display: inline-block" *ngIf="firstTimeFlow">
|
||||
<h3 class="card-title text-center">Create an Admin Account</h3>
|
||||
<div class="card p-3">
|
||||
<p>Please create an admin account for yourself to start your reading journey.</p>
|
||||
<app-register-member (created)="onAdminCreated($event)" [firstTimeFlow]="firstTimeFlow"></app-register-member>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
|
||||
<div class="row row-cols-4 row-cols-md-4 row-cols-sm-2 row-cols-xs-2">
|
||||
<ng-container *ngFor="let member of memberNames">
|
||||
<div class="col align-self-center card p-3 m-3" style="width: 12rem;">
|
||||
<span tabindex="0" (click)="select(member)" a11y-click="13,32">
|
||||
<div class="logo-container">
|
||||
<h3 class="card-title text-center">{{member | sentenceCase}}</h3>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="card-text" #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed[member]" (keyup.enter)="$event.stopPropagation()">
|
||||
<div class="form-group" [ngStyle]="authDisabled ? {display: 'none'} : {}">
|
||||
<label for="username--{{member}}">Username</label>
|
||||
<input class="form-control" formControlName="username" id="username--{{member}}" type="text" [readonly]="authDisabled">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password--{{member}}">Password</label>
|
||||
<input class="form-control" formControlName="password" id="password--{{member}}" type="password" autofocus>
|
||||
<div *ngIf="authDisabled" class="invalid-feedback">
|
||||
Authentication is disabled. Only type password if this is an admin account.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-primary alt" type="submit--{{member}}">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
|
||||
<div class="card-text">
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input class="form-control" formControlName="username" id="username" type="text" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input class="form-control" formControlName="password" id="password" type="password">
|
||||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-primary alt" type="submit">Login</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -4,6 +4,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -61px; // To offset the navbar
|
||||
height: calc(100vh);
|
||||
min-height: 289px;
|
||||
position: relative;
|
||||
|
@ -22,32 +23,38 @@
|
|||
}
|
||||
|
||||
.logo-container {
|
||||
margin: 0 auto 15px;
|
||||
|
||||
.logo {
|
||||
display:inline-block;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-color;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
min-width: 300px;
|
||||
|
||||
&:focus {
|
||||
border: 2px solid white;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.card-title {
|
||||
font-family: 'Spartan', sans-serif;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
vertical-align: middle;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-family: "EBGaramond", "Helvetica Neue", sans-serif;
|
||||
|
||||
input {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.alt {
|
||||
|
@ -66,3 +73,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
display: inline-block;
|
||||
color: #343c59;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: #fff !important;
|
||||
}
|
|
@ -2,7 +2,9 @@ import { Component, OnInit } from '@angular/core';
|
|||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { first, take } from 'rxjs/operators';
|
||||
import { SettingsService } from '../admin/settings.service';
|
||||
import { User } from '../_models/user';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { MemberService } from '../_services/member.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
|
@ -17,27 +19,82 @@ export class UserLoginComponent implements OnInit {
|
|||
model: any = {username: '', password: ''};
|
||||
loginForm: FormGroup = new FormGroup({
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', [Validators.required])
|
||||
password: new FormControl('', [Validators.required])
|
||||
});
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService, private toastr: ToastrService, private navService: NavService) { }
|
||||
memberNames: Array<string> = [];
|
||||
isCollapsed: {[key: string]: boolean} = {};
|
||||
authDisabled: boolean = false;
|
||||
/**
|
||||
* If there are no admins on the server, this will enable the registration to kick in.
|
||||
*/
|
||||
firstTimeFlow: boolean = true;
|
||||
/**
|
||||
* Used for first time the page loads to ensure no flashing
|
||||
*/
|
||||
isLoaded: boolean = false;
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
|
||||
private toastr: ToastrService, private navService: NavService, private settingsService: SettingsService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// Validate that there are users so you can refresh to home. This is important for first installs
|
||||
this.validateAdmin();
|
||||
}
|
||||
|
||||
validateAdmin() {
|
||||
this.navService.hideNavBar();
|
||||
this.memberService.adminExists().subscribe(res => {
|
||||
if (!res) {
|
||||
this.router.navigateByUrl('/home');
|
||||
this.navService.showNavBar();
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.router.navigateByUrl('/library');
|
||||
}
|
||||
});
|
||||
|
||||
this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe((enabled: boolean) => {
|
||||
// There is a bug where this is coming back as a string not a boolean.
|
||||
this.authDisabled = enabled + '' === 'false';
|
||||
if (this.authDisabled) {
|
||||
this.loginForm.get('password')?.setValidators([]);
|
||||
|
||||
// This API is only useable on disabled authentication
|
||||
this.memberService.getMemberNames().pipe(take(1)).subscribe(members => {
|
||||
this.memberNames = members;
|
||||
const isOnlyOne = this.memberNames.length === 1;
|
||||
this.memberNames.forEach(name => this.isCollapsed[name] = !isOnlyOne);
|
||||
this.firstTimeFlow = members.length === 0;
|
||||
this.isLoaded = true;
|
||||
});
|
||||
} else {
|
||||
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
|
||||
this.firstTimeFlow = !adminExists;
|
||||
this.setupAuthenticatedLoginFlow();
|
||||
this.isLoaded = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
setupAuthenticatedLoginFlow() {
|
||||
if (this.memberNames.indexOf(' Login ') >= 0) { return; }
|
||||
this.memberNames.push(' Login ');
|
||||
this.memberNames.forEach(name => this.isCollapsed[name] = false);
|
||||
const lastLogin = localStorage.getItem(this.accountService.lastLoginKey);
|
||||
if (lastLogin != undefined && lastLogin != null && lastLogin != '') {
|
||||
this.loginForm.get('username')?.setValue(lastLogin);
|
||||
}
|
||||
}
|
||||
|
||||
onAdminCreated(user: User | null) {
|
||||
if (user != null) {
|
||||
this.firstTimeFlow = false;
|
||||
if (this.authDisabled) {
|
||||
this.isCollapsed[user.username] = true;
|
||||
this.select(user.username);
|
||||
this.memberNames.push(user.username);
|
||||
}
|
||||
} else {
|
||||
this.toastr.error('There was an issue creating the new user. Please refresh and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
login() {
|
||||
if (!this.loginForm.dirty || !this.loginForm.valid) { return; }
|
||||
this.model = {username: this.loginForm.get('username')?.value, password: this.loginForm.get('password')?.value};
|
||||
this.accountService.login(this.model).subscribe(() => {
|
||||
this.loginForm.reset();
|
||||
|
@ -45,7 +102,7 @@ export class UserLoginComponent implements OnInit {
|
|||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||
if (pageResume && pageResume !== '/no-connection') {
|
||||
if (pageResume && pageResume !== '/no-connection' && pageResume !== '/login') {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
|
@ -62,4 +119,24 @@ export class UserLoginComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
select(member: string) {
|
||||
// This is a special case
|
||||
if (member === ' Login ' && !this.authDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginForm.get('username')?.setValue(member);
|
||||
|
||||
this.isCollapsed[member] = !this.isCollapsed[member];
|
||||
this.collapseAllButName(member);
|
||||
// ?! Scroll to the newly opened element?
|
||||
}
|
||||
|
||||
collapseAllButName(name: string) {
|
||||
Object.keys(this.isCollapsed).forEach(key => {
|
||||
if (key !== name) {
|
||||
this.isCollapsed[key] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,35 +5,14 @@
|
|||
<title>Kavita</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- <link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">
|
||||
<link rel="icon" sizes="72x72" href="assets/icons/android-icon-72x72.png">
|
||||
|
||||
<link rel="shortcut icon" href="assets/icons/android-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" href="assets/icons/apple-icon.png">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="assets/icons/apple-icon-57x57-filled.png">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="assets/icons/apple-icon-114x114-filled.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="assets/icons/apple-icon-144x144-filled.png">
|
||||
|
||||
<meta name="msapplication-square70x70logo" content="assets/icons/ms-icon-70x70.png">
|
||||
<meta name="msapplication-square150x150logo" content="assets/icons/ms-icon-150x150.png">
|
||||
<meta name="msapplication-square310x310logo" content="assets/icons/ms-icon-310x310.png">
|
||||
<meta name="theme-color" content="#4ac694"> -->
|
||||
|
||||
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/favicon-16x16.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link rel="shortcut icon" href="assets/icons/favicon.ico">
|
||||
<meta name="msapplication-TileColor" content="#4ac694">
|
||||
<meta name="msapplication-config" content="assets/icons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/favicon-16x16.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link rel="shortcut icon" href="assets/icons/favicon.ico">
|
||||
<meta name="msapplication-TileColor" content="#4ac694">
|
||||
<meta name="msapplication-config" content="assets/icons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
</head>
|
||||
<body class="mat-typography" theme="dark">
|
||||
|
|
|
@ -2,13 +2,21 @@ import { enableProdMode } from '@angular/core';
|
|||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { ConfigData } from './app/_models/config-data';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
||||
function fetchConfig(): Promise<ConfigData> {
|
||||
return fetch(environment.apiUrl + 'settings/base-url')
|
||||
.then(response => response.text())
|
||||
.then(response => new ConfigData(response));
|
||||
}
|
||||
|
||||
fetchConfig().then(config => {
|
||||
platformBrowserDynamic([ { provide: ConfigData, useValue: config } ])
|
||||
.bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue