Merged develop into main

This commit is contained in:
Joseph Milazzo 2021-10-12 08:21:43 -05:00
commit aa710529f0
151 changed files with 4393 additions and 1703 deletions

View 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;
}
}

View file

@ -0,0 +1,4 @@
export interface RefreshMetadataEvent {
libraryId: number;
seriesId: number;
}

View file

@ -0,0 +1,5 @@
export interface ScanLibraryProgressEvent {
libraryId: number;
progress: number;
eventTime: string;
}

View file

@ -1,3 +1,4 @@
export interface ScanSeriesEvent {
seriesId: number;
seriesName: string;
}

View file

@ -0,0 +1,5 @@
export interface SeriesAddedEvent {
libraryId: number;
seriesId: number;
seriesName: string;
}

View file

@ -0,0 +1,4 @@
export interface SeriesAddedToCollectionEvent {
tagId: number;
seriesId: number;
}

View file

@ -7,7 +7,7 @@ export enum LibraryType {
export interface Library {
id: number;
name: string;
coverImage: string;
lastScanned: string;
type: LibraryType;
folders: string[];
}

View file

@ -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}) {

View file

@ -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) {

View file

@ -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');
}

View file

@ -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) {

View file

@ -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));
}
}

View file

@ -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">&times;</span>
</button>

View file

@ -6,4 +6,6 @@ export interface ServerSettings {
port: number;
allowStatCollection: boolean;
enableOpds: boolean;
enableAuthentication: boolean;
baseUrl: string;
}

View file

@ -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>

View file

@ -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">&nbsp;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>&nbsp;
<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">

View file

@ -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() {

View file

@ -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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>

View file

@ -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);
});

View file

@ -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>

View file

@ -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();
}

View file

@ -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'});
}
}

View file

@ -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({

View file

@ -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);

View file

@ -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]

View file

@ -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">&nbsp;{{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'}}&nbsp;</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>

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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">&times;</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>&nbsp;
</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>&nbsp;
{{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>

View file

@ -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;

View file

@ -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">

View file

@ -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>

View file

@ -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();

View file

@ -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):

View file

@ -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'])

View file

@ -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>

View file

@ -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');
}
}
}

View file

@ -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'])

View file

@ -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() {

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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) {

View file

@ -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);
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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:

View file

@ -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)
);
}
}

View file

@ -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;

View 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);
}
}

View file

@ -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,

View file

@ -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>

View file

@ -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;
}

View file

@ -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;
}
});
}
}

View file

@ -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">

View file

@ -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));
});