Personal Table of Contents (#2148)
* Fixed a bad default setting for token key * Changed the payment link to support Google Pay * Fixed duplicate events occurring on newly added series from a scan. Fixed the version update code from not firing and made it check every 4-6 hours (random per user per restart) * Check for new releases on startup as well. Added Personal Table of Contents (called Bookmarks on epub and pdf reader). The idea is that sometimes you want to bookmark certain parts of pages to get back to quickly later. This mechanism will allow you to do that without having to edit the underlying ToC. * Added a button to update modal to show how to update for those unaware. * Darkened the link text within tables to be more visible. * Update link for how to update now is dynamic for docker users * Refactored to send proper star/end dates for scrobble read events for upcoming changes in the API. Added GoogleBooks Rating UI code if I go forward with API changes. * When Scrobbling, send when the first and last progress for the series was. Added OpenLibrary icon for upcoming enhancements for Kavita+. Changed the Update checker to execute at start. * Fixed backups not saving favicons in the correct place * Refactored the layout code for Personal ToC * More bugfixes around toc * Box alignment * Fixed up closing the overlay when bookmark mode is active * Fixed up closing the overlay when bookmark mode is active --------- Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
f3b8074b3a
commit
a0a6da9c60
53 changed files with 3538 additions and 244 deletions
8
UI/Web/src/app/_models/readers/personal-toc.ts
Normal file
8
UI/Web/src/app/_models/readers/personal-toc.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface PersonalToC {
|
||||
chapterId: number;
|
||||
pageNumber: number;
|
||||
title: string;
|
||||
bookScrollId: string | undefined;
|
||||
/* Ui Only */
|
||||
position: 0;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {DestroyRef, inject, Injectable} from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import {DestroyRef, Inject, inject, Injectable} from '@angular/core';
|
||||
import {DOCUMENT, Location} from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
|
@ -17,9 +17,8 @@ import { FileDimension } from '../manga-reader/_models/file-dimension';
|
|||
import screenfull from 'screenfull';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { AccountService } from './account.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { OnDestroy } from '@angular/core';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
export const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
|
@ -279,4 +278,51 @@ export class ReaderService {
|
|||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
removePersonalToc(chapterId: number, pageNumber: number, title: string) {
|
||||
return this.httpClient.delete(this.baseUrl + `reader/ptoc?chapterId=${chapterId}&pageNum=${pageNumber}&title=${encodeURIComponent(title)}`);
|
||||
}
|
||||
|
||||
getPersonalToC(chapterId: number) {
|
||||
return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId});
|
||||
}
|
||||
|
||||
getElementFromXPath(path: string) {
|
||||
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||
return node as Element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param pureXPath Will ignore shortcuts like id('')
|
||||
*/
|
||||
getXPathTo(element: any, pureXPath = false): string {
|
||||
if (element === null) return '';
|
||||
if (!pureXPath) {
|
||||
if (element.id !== '') { return 'id("' + element.id + '")'; }
|
||||
if (element === document.body) { return element.tagName; }
|
||||
}
|
||||
|
||||
|
||||
let ix = 0;
|
||||
const siblings = element.parentNode?.childNodes || [];
|
||||
for (let sibling of siblings) {
|
||||
if (sibling === element) {
|
||||
return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
|
||||
}
|
||||
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
|
||||
ix++;
|
||||
}
|
||||
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,32 +1,20 @@
|
|||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import { of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { filter, map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Preferences } from '../_models/preferences/preferences';
|
||||
import { User } from '../_models/user';
|
||||
import { Router } from '@angular/router';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { InviteUserResponse } from '../_models/auth/invite-user-response';
|
||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
import { UpdateEmailResponse } from '../_models/auth/update-email-response';
|
||||
import { AgeRating } from '../_models/metadata/age-rating';
|
||||
import { AgeRestriction } from '../_models/metadata/age-restriction';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ScrobbleError} from "../_models/scrobbling/scrobble-error";
|
||||
import {ScrobbleEvent} from "../_models/scrobbling/scrobble-event";
|
||||
import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold";
|
||||
import {PaginatedResult, Pagination} from "../_models/pagination";
|
||||
import {PaginatedResult} from "../_models/pagination";
|
||||
import {ScrobbleEventFilter} from "../_models/scrobbling/scrobble-event-filter";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {ReadingList} from "../_models/reading-list";
|
||||
|
||||
export enum ScrobbleProvider {
|
||||
Kavita = 0,
|
||||
AniList= 1,
|
||||
Mal = 2,
|
||||
GoogleBooks = 3
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -34,7 +22,6 @@ export enum ScrobbleProvider {
|
|||
})
|
||||
export class ScrobblingService {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<ng-template #emailServiceTooltip>Use fully qualified URL of the email service. Do not include ending slash.</ng-template>
|
||||
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url" aria-describedby="change-bookmarks-dir">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url">
|
||||
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
|
||||
Reset
|
||||
</button>
|
||||
|
|
|
@ -13,29 +13,30 @@
|
|||
|
||||
<h3>More Info</h3>
|
||||
<hr/>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col-4">Home page:</div>
|
||||
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Wiki:</div>
|
||||
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Discord:</div>
|
||||
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Donations:</div>
|
||||
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Source:</div>
|
||||
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Feature Requests:</div>
|
||||
<div class="col"><a href="https://feats.kavitareader.com" target="_blank" rel="noopener noreferrer">https://feats.kavitareader.com</a><br/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Wiki:</div>
|
||||
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Discord:</div>
|
||||
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Donations:</div>
|
||||
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Source:</div>
|
||||
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Feature Requests:</div>
|
||||
<div class="col"><a href="https://feats.kavitareader.com" target="_blank" rel="noopener noreferrer">https://feats.kavitareader.com</a><br/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<div #container class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None"
|
||||
[ngStyle]="{ 'top.px': overlayPosition.top, 'left.px': overlayPosition.left }">
|
||||
|
||||
<!-- Todo: close button or something -->
|
||||
<ng-container [ngSwitch]="mode">
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
|
||||
<div class="row g-0">
|
||||
<button class="btn btn-icon" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Create Bookmark</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
|
||||
<form [formGroup]="bookmarkForm">
|
||||
<div class="input-group">
|
||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" placeholder="Bookmark Name"
|
||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">Save</button>
|
||||
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
|
||||
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
|
||||
</div>
|
|
@ -0,0 +1,9 @@
|
|||
.overlay {
|
||||
position: absolute;
|
||||
background-color: rgb(0, 0, 0);
|
||||
color: white;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
max-width: 285px; /* Optional: limit the width of the overlay box */
|
||||
z-index: 9999; /* Ensure it's displayed above other elements */
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import {
|
||||
ChangeDetectionStrategy, ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef, EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit, Output,
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {fromEvent, of} from "rxjs";
|
||||
import {catchError, filter, tap} from "rxjs/operators";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import getBoundingClientRect from "@popperjs/core/lib/dom-utils/getBoundingClientRect";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {ReaderService} from "../../../_services/reader.service";
|
||||
|
||||
enum BookLineOverlayMode {
|
||||
None = 0,
|
||||
Bookmark = 1
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-line-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './book-line-overlay.component.html',
|
||||
styleUrls: ['./book-line-overlay.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BookLineOverlayComponent implements OnInit {
|
||||
@Input({required: true}) libraryId!: number;
|
||||
@Input({required: true}) seriesId!: number;
|
||||
@Input({required: true}) volumeId!: number;
|
||||
@Input({required: true}) chapterId!: number;
|
||||
@Input({required: true}) pageNumber: number = 0;
|
||||
@Input({required: true}) parent: ElementRef | undefined;
|
||||
@Output() refreshToC: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
xPath: string = '';
|
||||
selectedText: string = '';
|
||||
overlayPosition: { top: number; left: number } = { top: 0, left: 0 };
|
||||
mode: BookLineOverlayMode = BookLineOverlayMode.None;
|
||||
bookmarkForm: FormGroup = new FormGroup({
|
||||
name: new FormControl('', [Validators.required]),
|
||||
});
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
|
||||
get BookLineOverlayMode() { return BookLineOverlayMode; }
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
if (this.parent) {
|
||||
fromEvent<MouseEvent>(this.parent.nativeElement, 'mouseup')
|
||||
.pipe(takeUntilDestroyed(this.destroyRef),
|
||||
tap((event: MouseEvent) => {
|
||||
const selection = window.getSelection();
|
||||
if (!event.target) return;
|
||||
|
||||
if (this.mode !== BookLineOverlayMode.None && (!selection || selection.toString().trim() === '')) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedText = selection ? selection.toString().trim() : '';
|
||||
|
||||
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
|
||||
// Get x,y coord so we can position overlay
|
||||
if (event.target) {
|
||||
const range = selection!.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect();
|
||||
const box = getBoundingClientRect(event.target as Element);
|
||||
this.xPath = this.readerService.getXPathTo(event.target);
|
||||
if (this.xPath !== '') {
|
||||
this.xPath = '//' + this.xPath;
|
||||
}
|
||||
|
||||
this.overlayPosition = {
|
||||
top: rect.top + window.scrollY - 64 - rect.height, // 64px is the top menu area
|
||||
left: rect.left + window.scrollX + 30 // Adjust 10 to center the overlay box horizontally
|
||||
};
|
||||
}
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
switchMode(mode: BookLineOverlayMode) {
|
||||
this.mode = mode;
|
||||
this.cdRef.markForCheck();
|
||||
if (this.mode === BookLineOverlayMode.Bookmark) {
|
||||
this.bookmarkForm.get('name')?.setValue(this.selectedText);
|
||||
this.focusOnBookmarkInput();
|
||||
}
|
||||
}
|
||||
|
||||
createPTOC() {
|
||||
this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber,
|
||||
this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => {
|
||||
this.focusOnBookmarkInput();
|
||||
return of();
|
||||
})).subscribe(() => {
|
||||
this.reset();
|
||||
this.refreshToC.emit();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
focusOnBookmarkInput() {
|
||||
if (this.mode !== BookLineOverlayMode.Bookmark) return;
|
||||
setTimeout(() => this.elementRef.nativeElement.querySelector('#bookmark-name')?.focus(), 10);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.bookmarkForm.reset();
|
||||
this.mode = BookLineOverlayMode.None;
|
||||
this.xPath = '';
|
||||
this.selectedText = '';
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
|
@ -63,7 +63,23 @@
|
|||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>Table of Contents</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-table-of-contents [chapters]="chapters" [chapterId]="chapterId" [pageNum]="pageNum" [currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
||||
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>ToC</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-table-of-contents [chapters]="chapters" [chapterId]="chapterId" [pageNum]="pageNum"
|
||||
[currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.PersonalTableOfContents">
|
||||
<a ngbNavLink>Bookmarks</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-personal-table-of-contents [chapterId]="chapterId" [pageNum]="pageNum" (loadChapter)="loadChapterPart($event)"
|
||||
[tocRefresh]="refreshPToC"></app-personal-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -90,8 +106,6 @@
|
|||
[ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}"
|
||||
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
||||
[innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
|
||||
|
||||
|
||||
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default) && !(writingStyle === WritingStyle.Vertical && layoutMode === BookPageLayoutMode.Default)"
|
||||
(click)="$event.stopPropagation();"
|
||||
[ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
|
||||
|
@ -99,7 +113,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #actionBar>
|
||||
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
|
@ -133,3 +146,12 @@
|
|||
</ng-template>
|
||||
|
||||
</div>
|
||||
|
||||
<app-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
|
||||
[libraryId]="libraryId"
|
||||
[volumeId]="volumeId"
|
||||
[chapterId]="chapterId"
|
||||
[seriesId]="seriesId"
|
||||
[pageNumber]="pageNum"
|
||||
(refreshToC)="refreshPersonalToC()">
|
||||
</app-book-line-overlay>
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
ElementRef,
|
||||
ElementRef, EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Inject,
|
||||
|
@ -16,8 +16,8 @@ import {
|
|||
import { DOCUMENT, Location, NgTemplateOutlet, NgIf, NgStyle, NgClass } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin, fromEvent, of, Subject } from 'rxjs';
|
||||
import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { forkJoin, fromEvent, of } from 'rxjs';
|
||||
import { catchError, debounceTime, take } from 'rxjs/operators';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { NavService } from 'src/app/_services/nav.service';
|
||||
|
@ -46,13 +46,20 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|||
import { TableOfContentsComponent } from '../table-of-contents/table-of-contents.component';
|
||||
import { NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DrawerComponent } from '../../../shared/drawer/drawer.component';
|
||||
import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component";
|
||||
import {
|
||||
PersonalTableOfContentsComponent,
|
||||
PersonalToCEvent
|
||||
} from "../personal-table-of-contents/personal-table-of-contents.component";
|
||||
|
||||
|
||||
enum TabID {
|
||||
Settings = 1,
|
||||
TableOfContents = 2
|
||||
TableOfContents = 2,
|
||||
PersonalTableOfContents = 3
|
||||
}
|
||||
|
||||
|
||||
interface HistoryPoint {
|
||||
/**
|
||||
* Page Number
|
||||
|
@ -94,7 +101,7 @@ const elementLevelStyles = ['line-height', 'font-family'];
|
|||
])
|
||||
],
|
||||
standalone: true,
|
||||
imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip]
|
||||
imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, BookLineOverlayComponent, PersonalTableOfContentsComponent]
|
||||
})
|
||||
export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
|
@ -150,6 +157,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
* Belongs to the drawer component
|
||||
*/
|
||||
activeTabId: TabID = TabID.Settings;
|
||||
/**
|
||||
* Sub Nav tab id
|
||||
*/
|
||||
tocId: TabID = TabID.TableOfContents;
|
||||
/**
|
||||
* Belongs to drawer component
|
||||
*/
|
||||
|
@ -280,6 +291,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
writingStyle: WritingStyle = WritingStyle.Horizontal;
|
||||
|
||||
/**
|
||||
* Used to refresh the Personal PoC
|
||||
*/
|
||||
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
|
||||
|
@ -666,6 +682,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA';
|
||||
if (isInputFocused) return;
|
||||
|
||||
if (event.key === KEY_CODES.RIGHT_ARROW) {
|
||||
this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS);
|
||||
} else if (event.key === KEY_CODES.LEFT_ARROW) {
|
||||
|
@ -783,6 +803,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.loadPage('id("' + event.part + '")');
|
||||
}
|
||||
|
||||
/**
|
||||
* From personal table of contents/bookmark
|
||||
* @param event
|
||||
*/
|
||||
loadChapterPart(event: PersonalToCEvent) {
|
||||
this.setPageNum(event.pageNum);
|
||||
this.loadPage(event.scrollPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value
|
||||
* from 'kavita-part', which will cause the reader to scroll to the marker.
|
||||
|
@ -987,7 +1016,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
else {
|
||||
this.reader.nativeElement.children
|
||||
// We need to check if we are paging back, because we need to adjust the scroll
|
||||
if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) {
|
||||
setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement));
|
||||
|
@ -1213,7 +1241,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
intersectingEntries.sort(this.sortElements);
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
let path = this.getXPathTo(intersectingEntries[0]);
|
||||
let path = this.readerService.getXPathTo(intersectingEntries[0]);
|
||||
if (path === '') { return; }
|
||||
if (!path.startsWith('id')) {
|
||||
path = '//html[1]/' + path;
|
||||
|
@ -1339,35 +1367,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
getElementFromXPath(path: string) {
|
||||
const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
const node = this.document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||
return node as Element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getXPathTo(element: any): string {
|
||||
if (element === null) return '';
|
||||
if (element.id !== '') { return 'id("' + element.id + '")'; }
|
||||
if (element === this.document.body) { return element.tagName; }
|
||||
|
||||
|
||||
let ix = 0;
|
||||
const siblings = element.parentNode?.childNodes || [];
|
||||
for (let sibling of siblings) {
|
||||
if (sibling === element) {
|
||||
return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
|
||||
}
|
||||
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
|
||||
ix++;
|
||||
}
|
||||
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
|
||||
*/
|
||||
|
@ -1583,4 +1590,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.mousePosition.x = $event.screenX;
|
||||
this.mousePosition.y = $event.screenY;
|
||||
}
|
||||
|
||||
refreshPersonalToC() {
|
||||
this.refreshPToC.emit();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<div class="table-of-contents">
|
||||
<div *ngIf="Pages.length === 0">
|
||||
<em>Nothing Bookmarked yet</em>
|
||||
</div>
|
||||
<ul>
|
||||
<li *ngFor="let page of Pages">
|
||||
<span (click)="loadChapterPage(page, '')">Page {{page}}</span>
|
||||
<ul class="chapter-title">
|
||||
<li class="ellipsis"
|
||||
[ngbTooltip]="bookmark.title"
|
||||
placement="right"
|
||||
*ngFor="let bookmark of bookmarks[page]" (click)="loadChapterPage(bookmark.pageNumber, bookmark.bookScrollId); $event.stopPropagation();">
|
||||
{{bookmark.title}}
|
||||
<button class="btn btn-icon ms-1" (click)="removeBookmark(bookmark); $event.stopPropagation();">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Delete {{bookmark.title}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,15 @@
|
|||
.table-of-contents li {
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef, EventEmitter,
|
||||
Inject,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {CommonModule, DOCUMENT} from '@angular/common';
|
||||
import {ReaderService} from "../../../_services/reader.service";
|
||||
import {PersonalToC} from "../../../_models/readers/personal-toc";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
export interface PersonalToCEvent {
|
||||
pageNum: number;
|
||||
scrollPart: string | undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-personal-table-of-contents',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgbTooltip],
|
||||
templateUrl: './personal-table-of-contents.component.html',
|
||||
styleUrls: ['./personal-table-of-contents.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PersonalTableOfContentsComponent implements OnInit {
|
||||
|
||||
@Input({required: true}) chapterId!: number;
|
||||
@Input({required: true}) pageNum: number = 0;
|
||||
@Input({required: true}) tocRefresh!: EventEmitter<void>;
|
||||
@Output() loadChapter: EventEmitter<PersonalToCEvent> = new EventEmitter();
|
||||
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
bookmarks: {[key: number]: Array<PersonalToC>} = [];
|
||||
|
||||
get Pages() {
|
||||
return Object.keys(this.bookmarks).map(p => parseInt(p, 10));
|
||||
}
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.load();
|
||||
});
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.readerService.getPersonalToC(this.chapterId).subscribe(res => {
|
||||
res.forEach(t => {
|
||||
if (!this.bookmarks.hasOwnProperty(t.pageNumber)) {
|
||||
this.bookmarks[t.pageNumber] = [];
|
||||
}
|
||||
this.bookmarks[t.pageNumber].push(t);
|
||||
})
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
loadChapterPage(pageNum: number, scrollPart: string | undefined) {
|
||||
this.loadChapter.emit({pageNum, scrollPart});
|
||||
}
|
||||
|
||||
removeBookmark(bookmark: PersonalToC) {
|
||||
this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => {
|
||||
this.bookmarks[bookmark.pageNumber] = this.bookmarks[bookmark.pageNumber].filter(t => t.title != bookmark.title);
|
||||
|
||||
if (this.bookmarks[bookmark.pageNumber].length === 0) {
|
||||
delete this.bookmarks[bookmark.pageNumber];
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { BookChapterItem } from '../../_models/book-chapter-item';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
|
||||
|
@ -11,7 +10,7 @@ import { NgIf, NgFor } from '@angular/common';
|
|||
standalone: true,
|
||||
imports: [NgIf, NgFor]
|
||||
})
|
||||
export class TableOfContentsComponent implements OnDestroy {
|
||||
export class TableOfContentsComponent {
|
||||
|
||||
@Input({required: true}) chapterId!: number;
|
||||
@Input({required: true}) pageNum!: number;
|
||||
|
@ -20,17 +19,8 @@ export class TableOfContentsComponent implements OnDestroy {
|
|||
|
||||
@Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter();
|
||||
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
pageAnchors: {[n: string]: number } = {};
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
cleanIdSelector(id: string) {
|
||||
const tokens = id.split('/');
|
||||
if (tokens.length > 0) {
|
||||
|
|
|
@ -5,12 +5,6 @@ import { environment } from 'src/environments/environment';
|
|||
import { BookChapterItem } from '../_models/book-chapter-item';
|
||||
import { BookInfo } from '../_models/book-info';
|
||||
|
||||
export interface BookPage {
|
||||
bookTitle: string;
|
||||
styles: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface FontFamily {
|
||||
/**
|
||||
* What the user should see
|
||||
|
@ -32,7 +26,7 @@ export class BookService {
|
|||
constructor(private http: HttpClient) { }
|
||||
|
||||
getFontFamilies(): Array<FontFamily> {
|
||||
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
|
||||
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
|
||||
{title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'},
|
||||
{title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}];
|
||||
}
|
||||
|
|
|
@ -72,6 +72,7 @@ export class DashboardComponent implements OnInit {
|
|||
|
||||
|
||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||
if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return;
|
||||
this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries];
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
|
|
@ -9,8 +9,8 @@ import {
|
|||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NgbModal, NgbModalRef, NgbPopover } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
|
||||
import { map, shareReplay, takeUntil } from 'rxjs/operators';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
|
@ -79,7 +79,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event: Message<NotificationProgressEvent>) => {
|
||||
if (event.event === EVENTS.NotificationProgress) {
|
||||
this.processNotificationProgressEvent(event);
|
||||
} else if (event.event === EVENTS.Error) {
|
||||
|
@ -94,6 +94,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
this.infoSource.next(values);
|
||||
this.activeEvents += 1;
|
||||
this.cdRef.markForCheck();
|
||||
} else if (event.event === EVENTS.UpdateAvailable) {
|
||||
console.log('event: ', event);
|
||||
this.handleUpdateAvailableClick(event.payload);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -150,10 +153,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
|
||||
handleUpdateAvailableClick(message: NotificationProgressEvent) {
|
||||
handleUpdateAvailableClick(message: NotificationProgressEvent | UpdateVersionEvent) {
|
||||
if (this.updateNotificationModalRef != null) { return; }
|
||||
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
this.updateNotificationModalRef.componentInstance.updateData = message.body as UpdateVersionEvent;
|
||||
if (message.hasOwnProperty('body')) {
|
||||
this.updateNotificationModalRef.componentInstance.updateData = (message as NotificationProgressEvent).body as UpdateVersionEvent;
|
||||
} else {
|
||||
this.updateNotificationModalRef.componentInstance.updateData = message as UpdateVersionEvent;
|
||||
}
|
||||
|
||||
this.updateNotificationModalRef.closed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
|
@ -176,7 +184,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
config.header = event.title;
|
||||
config.content = event.subTitle;
|
||||
var result = await this.confirmService.alert(event.subTitle || event.title, config);
|
||||
const result = await this.confirmService.alert(event.subTitle || event.title, config);
|
||||
if (result) {
|
||||
this.removeErrorOrInfo(event);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ export class ProviderImagePipe implements PipeTransform {
|
|||
return 'assets/images/ExternalServices/AniList.png';
|
||||
case ScrobbleProvider.Mal:
|
||||
return 'assets/images/ExternalServices/MAL.png';
|
||||
case ScrobbleProvider.GoogleBooks:
|
||||
return 'assets/images/ExternalServices/GoogleBooks.png';
|
||||
case ScrobbleProvider.Kavita:
|
||||
return 'assets/images/logo-32.png';
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title">New Update Available!</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5>{{updateData.updateTitle}}</h5>
|
||||
|
@ -10,6 +8,7 @@
|
|||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn {{updateData.isDocker ? 'btn-primary' : 'btn-secondary'}}" (click)="close()">Close</button>
|
||||
<a *ngIf="!updateData.isDocker" href="{{updateData.updateUrl}}" class="btn btn-primary" target="_blank" rel="noopener noreferrer" (click)="close()">Download</a>
|
||||
</div>
|
||||
<a class="btn btn-icon" [href]="updateUrl" target="_blank" rel="noopener noreferrer">How to Update</a>
|
||||
<button type="button" class="btn {{updateData.isDocker ? 'btn-primary' : 'btn-secondary'}}" (click)="close()">Close</button>
|
||||
<a *ngIf="!updateData.isDocker" href="{{updateData.updateUrl}}" class="btn btn-primary" target="_blank" rel="noopener noreferrer" (click)="close()">Download</a>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
|
||||
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
||||
import {CommonModule} from "@angular/common";
|
||||
|
@ -14,12 +14,21 @@ import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
|
|||
styleUrls: ['./update-notification-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class UpdateNotificationModalComponent {
|
||||
export class UpdateNotificationModalComponent implements OnInit {
|
||||
|
||||
@Input({required: true}) updateData!: UpdateVersionEvent;
|
||||
updateUrl: string = 'https://wiki.kavitareader.com/en/install/windows-install#updating-kavita';
|
||||
|
||||
constructor(public modal: NgbActiveModal) { }
|
||||
|
||||
ngOnInit() {
|
||||
if (this.updateData.isDocker) {
|
||||
this.updateUrl = 'https://wiki.kavitareader.com/en/install/docker-install#updating-kavita';
|
||||
} else {
|
||||
this.updateUrl = 'https://wiki.kavitareader.com/en/install/windows-install#updating-kavita';
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close({success: false, series: undefined});
|
||||
}
|
||||
|
|
BIN
UI/Web/src/assets/images/ExternalServices/GoogleBooks.png
Normal file
BIN
UI/Web/src/assets/images/ExternalServices/GoogleBooks.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 523 B |
BIN
UI/Web/src/assets/images/ExternalServices/OpenLibrary.png
Normal file
BIN
UI/Web/src/assets/images/ExternalServices/OpenLibrary.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
|
@ -5,6 +5,6 @@ export const environment = {
|
|||
production: true,
|
||||
apiUrl: `${BASE_URL}api/`,
|
||||
hubUrl:`${BASE_URL}hubs/`,
|
||||
buyLink: 'https://buy.stripe.com/fZe6qsbrJ8bye88cMO?prefilled_promo_code=FREETRIAL',
|
||||
buyLink: 'https://buy.stripe.com/3cs7uw67p2Re7JK4gj?prefilled_promo_code=FREETRIAL',
|
||||
manageLink: 'https://billing.stripe.com/p/login/28oaFRa3HdHWb5ecMM'
|
||||
};
|
||||
|
|
|
@ -26,6 +26,10 @@ a.read-more-link {
|
|||
}
|
||||
}
|
||||
|
||||
td > a:not(.dark-exempt) {
|
||||
color: var(--primary-color-darker-shade);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue