v0.7.4 - Kavita+ Launch (#2117)

* Initial Canary Push (#2055)

* Added AniList Token

* Implemented the ability to set your AniList token. License check is not in place.

* Added a check that validates AniList token is still valid. As I build out more support, I will add more checks.

* Refactored the code to validate the license before allowing UI control to be edited.

* Started license server stuff, but may need to change approach.

Hooked up ability to scrobble rating events to KavitaPlus API.

* Hooked in the ability to sync Mark Series as Read/Unread

* Fixed up unit tests and only scrobble when a full chapter is read naturally.

* Fixed up the Scrobbling service

* Tweak one of the queries

* Started an idea for Scrobble History, might rework into generic TaskHistory.

* AniList Token now has a validation check.

* Implemented a mechanism such that events are persisted to the database, processed every X hours to the API layer, then deleted from the database.

* Hooked in code for want to read so we only send what's important. Will migrate these to bulk calls to lessen strain on API server.

* Added some todos. Need to take a break.

* Hooked up the ability to backfill scrobble events after turning it on.

* Started on integrating license key into the server and ability to turn off scrobbling at the library level. Added sync history table for scrobbling and other API based information.

* Started writing to sync table

* Refactored the migrations to flatten them.

Started working a basic license add flow and added in some of the cache. Lots to do.

* Ensure that when we backfill scrobble events, we respect if a library has scrobbling turned on or not.

* Hooked up the ability to send when the series was started to be read

* Refactored the UI to streamline and group KavitaPlus Account Forms.

* Aligning with API

* Fixed bad merge

* Fixed up inputting a user license.

* Hooked up a cron task that validates licenses every 4 hours and on startup.

* Reworked how the update license code works so that we always update the cache and we handle removing license from user.

* Cleaned up some UI code

* UserDto now has if there is a valid license or not. It's not exposed though as there is no need to expose the license key ever.

* Fixed a strange encoding issue with extra ".

Started working on having the UI aware of the license information.

Refactored all code to properly pass the correct license to the API layer.

* There is a circular dependency in the code.

Fixed some theme code which wasn't checking the right variable.

Reworked the JWT interceptor to be better at handling async code.

Lots of misc code changes, DI circular issue is still present.

* Fixed the DI issue and moved all things that need bootstrapping to app.component.

* Hooked up the ability to not have a donation button show up if the server default user/admin has a valid KavitaPlus license.

* Refactored how we extract out ids from weblinks

* Ensure if API fails, we don't delete the record.

* Refactored how rate checks occur for scrobbling processing.

* Lots of testing and ensuring rate limit doesn't get destroyed.

* Ensure the media item is valid for that user's providers set.

* Refactored the loop code into one method to keep things much cleaner

* Lots of code to get the scrobbling streamlined and foolproof. Unknown series are now reported on the UI.

* Prevent duplicates for scrobble errors.

* Ensure we are sending the correct type to the Scrobble Provider

* Ensure we send the date of the scrobble event for upstream to use.

* Replaced the dedicated run backfilling of scrobble events to just trigger when setting the anilist token for the first time.

Streamlined a lot of the code for adding your license to ensure user understands how it works.

* Fixed a bug where scan series wasn't triggering word count or cover generation.

* Started the plumbing for recommendations

* Merge conflicts

* Recommendation plumbing is nearly complete.

* Setup response caching and general cleanup

* Fixed UI not showing the recommendation tab

* Switched to prod url

* Fixed broken unit tests due to Hangfire not being setup for unit tests

* Fixed branch selection (#2056)

* Damn you GA (#2058)

* Bump versions by dotnet-bump-version.

* Fixed GA not pulling the right branch and removed unneeded building from veresion job (#2060)

* Bump versions by dotnet-bump-version.

* Canary Second (#2071)

* Just started

* Started building the user review card. Fixed Recommendations not having user progress on them.

* Fixed a bug where scrobbling ratings wasn't working.

* Added a temp ability to trigger scrobbling processing for testing.

* Cleaned up the design of review card. Added a temp way to trigger scrobbling.

* Fixed clear scrobbling errors and refactored so reviews now load from DB and is streamlined.

* Refactored so edit review is now a single module component and editable from the series detail page.

* Removed SyncHistory table as it's no longer needed. Refactored read events to properly update to the latest progress information. Refactored to a new way of clearing events, so that user's can see their scrobble history.

* Fixed a bug where Anilist token wouldn't show as set due to some state issue

* Added the ability to see your own scrobble events

* Avoid a potential collision with recommendations.

* Fixed an issue where when checking for a license on UI, it wouldn't force the check (in case server was down on first check).

* External reviews are implemented.

* Fixed unit tests

* Bump versions by dotnet-bump-version.

* Made the api url dynamic based on dev more or not. (#2072)

* Bump versions by dotnet-bump-version.

* Canary Build 3 (#2079)

* Updated reviews to have tagline support to match how Anilist has them.

Cleaned up the KavitaPlus documentation and added a feature list.

Review cards look much better.

* Fixed up a NPE in scrobble event creation

* Removed the ability to have images leak in the read more review card.

Review's now show the user if they are a local user, else External.

* Added caching to the reviews and recommendations that come from an external source. Max of 50MB will be used across whole instance. Entries are cached for 1 hour.

* Reviews are looking much better

* Added the ability for users to share their series reviews with other users on the server via a new opt-in mechanism.

Fixed up some cache busting mechanism for reviews.

* More review polish to align with better matching

* Added the extra information for Recommendation matching.

* Preview of the review is much cleaner now and the full body is styled better.

* More anilist specific syntax

* Fixed bad regex

* Added the ability to bust cache.

Spoilers are now implemented for reviews. Introduces:
--review-spoiler-bg-color
--review-spoiler-text-color

* Bump versions by dotnet-bump-version.

* Canary Build 4 (#2086)

* Updated Kavita Plus feature list. Added a hover-over to the progress bars in the app to know exact percentage of reading for a chapter or series.

* Added a button to go to external review. Changed how enums show in the documentation so you can see their string value too.

Limited reviews to top 10 with proper ordering. Drastically cleaned up how we handle preview summary generation

* Cleaned up the margin below review section

* Fixed an issue where a processed scrobble event would get updated instead of a new event created.

* By default, there is now a prompt on series review to add your own, which fills up the space nicely.

Added the backend for Series Holds.

* Scrobble History is now ordered by recent -> latest. Some minor cleanup in other files.

* Added a simple way to see and toggle scrobble service from the series.

* Fixed a bug where updating the user's last active time wasn't writing to database and causing a logout event.

* Tweaked the registration email wording to be more clear for email field.

* Improved OPDS Url generation and included using host name if defined.

* Fixed the issues with choosing the correct series cover image. Added many unit tests to cover the edge cases.

* Small cleanup

* Fixed an issue where urls with , in them would break weblinks.

* Fixed a bug where we weren't trying a png before we hit fallback for favicon parsing.

* Ensure scrobbling tab isn't active without a license.

Changed how updating user last active worked to supress more concurrency issues.

* Fixed an issue where duplicate series could appear on newly added during a scan.

* Bump versions by dotnet-bump-version.

* Fixed a bad dto (#2087)

* Bump versions by dotnet-bump-version.

* Canary Build 4 (#2089)

* New server-based auth is in place with the ability to register the instance.

* Refactored to single install bound licensing.

* Made the Kavita+ tab gold.

* Change the JWTs to last 10 days. This is a self-hosted software and the usage doesn't need the level of 2 days expiration

* Bump versions by dotnet-bump-version.

* Canary Build 4 (#2090)

* By default, a new library will only have scrobbling on if it's of type book or manga given current scrobble providers.

* Started building out external reviews.

* Added the ability to re-enter your license information.

* Fixed side nav not extending enough

* Fixed a bug with info cards

* Integrated rating support, fixed review cards without a tagline, and misc fixes.

* Streamlined where ratings are located on series detail page.

* Aligned with other series lookups

* Bump versions by dotnet-bump-version.

* Canary Build 6 (#2092)

* Cleaned up some messaging

* Fixed up series detail

* Cleanup

* Bump versions by dotnet-bump-version.

* Canary Build 6 (#2093)

* Fixed scrobble token not being visible by default.

* Added a loader for external reviews

* Added the ability to edit series details (weblinks) from Scrobble Issues page.

* Slightly lessened the focus on buttons

* Fixed review cards so whenever you click your own review, it will open the edit modal.

* Need for speed - Updated Kavita log to be much smaller and replaced all code ones with a 32x version.

* Optimized a ton of our images to be much smaller and faster to load.

* Added more MIME types for response compression

* Edit Series modal name field should be readonly as it is directly mapped to file metadata or filename parsed. It shouldn't be changeable via the UI.

* Removed the ability to update the Series name via Kavita UI/API as it is no longer editable.

* Moved Image component to be standalone

* Moved ReadMore component to be standalone

* Moved PersonBadge component to be standalone

* Moved IconAndTitle component to be standalone

* Fixed some bugs with standalone.

* Hooked in the ability to scrobble series reviews.

* Refactored everything to use HashUtil token rather than InstallId.

* Swapped over to a generated machine token and fixed an issue where after registering, the license would not say valid.

* Added the missing migration for review scrobble events.

* Clean up some wording around busting cache.

* Fixed a bug where chapters within a volume could be unordered in the UI info screen.

* Refactored to prepare for external series rendering on series detail.

* Implemented external recs

* Bump versions by dotnet-bump-version.

* Canary Build 7 (#2097)

* Aligned ExtractId to extract a long, since MAL id can be just that.

* Fixed external series card not clicking correctly.

Fixed a bug when extracting a Mal link.

Fixed cancel button on license component.

* Renamed user-license to license component given new direction for licensing.

* Implemented card layout for recommendations

* Moved more components over to be standalone and removed pipes module. This is going to take some time for sure.

* Removed Cards and SharedCardsSideNav and SideNav over to standalone. This has been shaken out.

* Cleaned up a bunch of extra space on reading list detail page.

* Fixed rating popover not having a black triangle.

* When checking license, show a loading indicator for validity icon.

* Cache size can now be changed by admins if they want to give more memory for better browsing.

* Added LastReadTime

* Cleanup the scrobbling control text for Library Settings.

* Fixed yet another edge case for getting series cover image where first volume is higher than 1 and the rest is just loose leaf chapters.

* Changed OPDS Content Type to be application/atom+xml to align better with the spec.

* Fixed unit tests

* Bump versions by dotnet-bump-version.

* Canary Build 7 (#2098)

* Fixed the percentage readout on card item progress bar

* Ensure scrobble control is always visible

* Review card could show person icon in tablet viewport.

* Changed how the ServerToken for node locking works as docker was giving different results each time.

* After we update series metadata, bust cache

* License componet cleanup on the styles

* Moved license to admin module and removed feature modal as wiki is much easier to maintain.

* Bump versions by dotnet-bump-version.

* Canary Build 8 (#2100)

* Fixed a very slight amount of the active nav tag bleeding outside the border radius

* Switched how we count words in epub to handle languages that don't have spaces.

* Updated dependencies and fixed a series cover image on list item view for recs.

* Fixed a bug where external recs werent showing summary of the series.

* Rewrote the rec loop to be cleaner

* Added the ability to see series summary on series detail page on list view.

Changed Scrobble Event page to show in server time and not utc.

* Added tons of output to identify why unraid generates a new fingerprint each time.

* Refactored scrobble event table to have filtering and pagination support.

Fixed a few bad template issues and fixed loading scrobbling tab on refresh of page.

* Aligned a few apis to use a default pagination rather than a higher level one.

* Undo OPDS change as Chunky/Panels break.

* Moved the holds code around

* Don't show an empty review for the user, it eats up uneeded space and is ugly.

* Cleaned up the review code

* Fixed a bug with arrow on sortable table header.

* More scrobbling debug information to ensure events are being processed correctly.

* Applied a ton of code cleanup build warnings

* Enhanced rec matching by prioritizing matching on weblinks before falling back to name matching.

* Fixed the calculation of word count for epubs.

* Bump versions by dotnet-bump-version.

* Canary Build 9 (#2104)

* Added another unit test

* Changed how we create cover images to force the aspect ratio, which allows for Kavita to do some extra work later down the line. Prevents skewing from comic sources.

* Code cleanup

* Updated signatures to explicitly indicate they return a physical file.

* Refactored the GA to be a bit more streamlined.

* Fixed up how after cover conversion, how we refresh volume and series image links.

* Undid the PhysicalFileResult stuff.

* Fixed an issue in the epub reader where html tags within an anchor could break the navigation code for inner-links.

* Fixed a bug in GetContinueChapter where a special could appear ahead of a loose leaf chapter.

* Optimized aspect ratios for custom library images to avoid shift layout.

Moved the series detail page down a bit to be inline with first row of actionables.

* Finally fixed the media conversion issue where volumes and series wouldn't get their file links updated.

* Added some new layout for license to allow a user to buy a sub after their last sub expired.

* Added more metrics for fingerprinting to test on docker.

* Tried to fix a bug with getnextchapter looping incorrectly, but unable to solve.

* Cleanup some UI stuff to reduce bad calls.

* Suppress annoying issues with reaching K+ when it's down (only affects local builds)

* Fixed an edge case bug for picking the correct cover image for a series.

* Fixed a bug where typeahead x wouldn't clear out the input field.

* Renamed Clear -> Reset for metadata filter to be more informative of its function.

* Don't allow duplicates for reading list characters.

* Fixed a bug where when calculating recently updated, series with the same name but different libraries could get grouped.

* Fixed an issue with fit to height where there could still be a small amount of scroll due to a timing issue with the image loading.

* Don't show a loading if the user doesn't have a license for external ratings

* Fixed bad stat url

* Fixed up licensing to make it so you have to email me to get a sub renewed.

* Updated deps

* When scrobbling reading events, recalculate the highest chapter/volume during processing.

* Code cleanup

* Disabled some old test code that is likely not needed as it breaks a lot on netvips updates

* Bump versions by dotnet-bump-version.

* Canary Build 10 (#2105)

* Aligned fingerprint to be unique

* Updated email button to have a template

* Fixed inability to progress to next chapter when last page is a spread and user is using split rendering.

* Attempted fix at the column reader cutting off parts of the words. Can't fully reproduce, but added a bit of padding to help.

* Aligned AniList icon to match that of weblinks.

* Bump versions by dotnet-bump-version.

* Canary Build 11 (#2108)

* Fixed an issue with continuous reader in manga reader.

* Aligned KavitaPlus->Kavita+

* Updated the readme

* Adjusted first time registration messaging.

* Fixed a bug where having just one type of weblink could cause a bad recommendation lookup

* Removed manual invocation of scrobbling as testing is over for that feature.

* Fixed a bad observerable for downloading logs from browser.

* Don't get reviews/recs for comic libraries. Override user selection for scrobbling on Comics since there are no places to scrobble to.

* Added a migration so all existing comic libraries will have scrobbling turned off.

* Don't allow the UI to toggle scrobbling on a library with no providers.

* Refactored the code to not throw generic 500 toasts on the UI. Added the ability to clear your license on Kavita side.

* Converted reader settings to new accordion format.

* Converted user preferences to new accordion format.

* I couldn't convert CBL Reading modal to new accordion directives due to some weird bug.

* Migrated the whole application to standalone components. This fixes the download progress bar not showing up.

* Hooked up the ability to have reading list generate random items. Removed the old code as it's no longer needed.

* Added random covers for collection's as well.

* Added a speed up to not regenerate merged covers if we've already created them.

* Fixed an issue where tooltips weren't styled correctly after updating a library. Migrated Library access modal to OnPush.

* Fixed broken table styling. Fixed grid breakpoint css variables not using the ones from variables due to a missing import.

* Misc fixes around tables and some api doc cleanup

* Fixed a bug where when switching from webtoon back to a non-webtoon reading mode, if the browser size isn't large enough for double, the reader wouldn't go to single mode.

* When combining external recs, normalize names to filter out differences, like capitalization.

* Finally get to update ExCSS to the latest version! This adds much more css properties for epubs.

* Ensure rejected reviews are saved as errors

* A crap ton of code cleanup

* Cleaned up some equality code in GenreHelper.cs

* Fixed up the table styling after the bootstrap update changed it.

* Bump versions by dotnet-bump-version.

* Canary Build 12 (#2111)

* Aligned GA (#2059)

* Fixed the code around merging images to resize them. This will only look correct if this release's cover generation runs.

* Misc code cleanup

* Fixed an issue with epub column layout cutting off text

* Collection detail page will now default sort by sort name.

* Explicitly lazy load library icon images.

* Make sure the full error message can be passed to the license component/user.

* Use WhereIf in some places

* Changed the hash util code for unraid again

* Fixed up an issue with split render mode where last page wouldn't move into the next chapter.

* Bump versions by dotnet-bump-version.

* Don't ask me how, but i think I fixed the epub cutoff issue (#2112)

* Bump versions by dotnet-bump-version.

* Canary 14 (#2113)

* Switched how we build the unraid fingerprint.

* Fixed a bit of space below the image on fit to height

* Removed some bad code

* Bump versions by dotnet-bump-version.

* Canary Build 15 (#2114)

* When performing a scan series, force a recount of words/pages to ensure read time gets updated.

* Fixed broken download logs button (develop)

* Sped up the query for getting libraries and added caching for that api, which is helpful for users with larger library counts.

* Fixed an issue in directory picker where if you had two folders with the same name, the 2nd to last wouldn't be clickable.

* Added more destroy ref stuff.

* Switched the buy/manage links over to be environment specific.

* Bump versions by dotnet-bump-version.

* Canary Build 16 (#2115)

* Added the promo code for K+ and version bump.

* Don't show see more if there isn't more to see on series detail.

* Bump versions by dotnet-bump-version.

* Last Build (#2116)

* Merge

* Close the view after removing a license key from server.

* Bump versions by dotnet-bump-version.

* Reset version to v0.7.4 for merge.
This commit is contained in:
Joe Milazzo 2023-07-11 13:14:18 -05:00 committed by GitHub
parent baf7c9eb92
commit a8ee1d2191
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
493 changed files with 30153 additions and 8096 deletions

View file

@ -4,15 +4,19 @@ root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.html]
indent_size = 2
[*.ts]
quote_type = single
indent_size = 2
[*.scss]
indent_size = 4
indent_size = 2
[*.md]
max_line_length = off

6354
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
"@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.5",
"@ng-bootstrap/ng-bootstrap": "^15.0.0",
"@ng-bootstrap/ng-bootstrap": "^15.1.0",
"@popperjs/core": "^2.11.7",
"@swimlane/ngx-charts": "^20.1.2",
"@tweenjs/tween.js": "^20.0.3",

View file

@ -26,8 +26,9 @@ img {
.full-height {
width: auto;
margin: 0 auto;
margin: auto;
max-height: calc(var(--vh)*100);
overflow: hidden; // This technically will crop and make it just fit
vertical-align: top;
&.wide {
height: 100vh;

View file

@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import {inject, Injectable} from '@angular/core';
import {
HttpRequest,
HttpHandler,
@ -13,7 +13,6 @@ import { AccountService } from '../_services/account.service';
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
@ -38,7 +37,7 @@ export class ErrorInterceptor implements HttpInterceptor {
this.handleServerException(error);
break;
default:
// Don't throw multiple Something undexpected went wrong
// Don't throw multiple Something unexpected went wrong
if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') {
this.toastr.error('Something unexpected went wrong.');
}
@ -50,7 +49,7 @@ export class ErrorInterceptor implements HttpInterceptor {
}
private handleValidationError(error: any) {
// This 400 can also be a bad request
// This 400 can also be a bad request
if (Array.isArray(error.error)) {
const modalStateErrors: any[] = [];
if (error.error.length > 0 && error.error[0].hasOwnProperty('message')) {
@ -82,8 +81,8 @@ export class ErrorInterceptor implements HttpInterceptor {
console.error('error:', error);
if (error.statusText === 'Bad Request') {
if (error.error instanceof Blob) {
this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status);
return;
this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status);
return;
}
this.toastr.error(error.error, error.status + ' Error');
} else {
@ -93,7 +92,7 @@ export class ErrorInterceptor implements HttpInterceptor {
}
private handleNotFound(error: any) {
this.toastr.error('That url does not exist.');
this.toastr.error('That url does not exist.');
}
private handleServerException(error: any) {
@ -107,7 +106,8 @@ export class ErrorInterceptor implements HttpInterceptor {
if (error.message != 'User is not authenticated') {
console.error('500 error: ', error);
}
this.toastr.error(error.message);
// This just throws duplicate errors for no reason
//this.toastr.error(error.message);
}
else {
this.toastr.error('There was an unknown critical error.');
@ -121,9 +121,8 @@ export class ErrorInterceptor implements HttpInterceptor {
if (location.href.includes('/registration/confirm-email?token=')) {
return;
}
// NOTE: Signin has error.error or error.statusText available.
// NOTE: Signin has error.error or error.statusText available.
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
this.accountService.logout();
this.router.navigateByUrl('/login');
}
}

View file

@ -1,11 +1,11 @@
import { Injectable } from '@angular/core';
import {inject, Injectable} from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
import {Observable, switchMap} from 'rxjs';
import { AccountService } from '../_services/account.service';
import { take } from 'rxjs/operators';
@ -15,18 +15,17 @@ export class JwtInterceptor implements HttpInterceptor {
constructor(private accountService: AccountService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Take 1 means we don't have to unsubscribe because we take 1 then complete
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${user.token}`
}
});
}
});
return next.handle(request);
return this.accountService.currentUser$.pipe(
take(1),
switchMap(user => {
if (user) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${user.token}`
}
});
}
return next.handle(request);
}));
}
}

View file

@ -17,5 +17,6 @@ export interface Library {
includeInSearch: boolean;
manageCollections: boolean;
manageReadingLists: boolean;
allowScrobbling: boolean;
collapseSeriesRelationships: boolean;
}
}

View file

@ -41,6 +41,7 @@ export interface Preferences {
promptForDownloadSize: boolean;
noTransitions: boolean;
collapseSeriesRelationships: boolean;
shareReviews: boolean;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];

View file

@ -0,0 +1,8 @@
import {ScrobbleProvider} from "../_services/scrobbling.service";
export interface Rating {
averageScore: number;
meanScore: number;
favoriteCount: number;
provider: ScrobbleProvider;
}

View file

@ -0,0 +1,7 @@
export interface ScrobbleError {
comment: string;
details: string;
seriesId: number;
libraryId: number;
created: string;
}

View file

@ -0,0 +1,14 @@
export enum ScrobbleEventSortField {
None = 0,
Created = 1,
LastModified = 2,
Type= 3,
Series = 4,
IsProcessed = 5
}
export interface ScrobbleEventFilter {
field: ScrobbleEventSortField;
isDescending: boolean;
query?: string;
}

View file

@ -0,0 +1,21 @@
export enum ScrobbleEventType {
ChapterRead = 0,
AddWantToRead = 1,
RemoveWantToRead = 2,
ScoreUpdated = 3,
Review = 4
}
export interface ScrobbleEvent {
seriesName: string;
seriesId: number;
libraryId: number;
isProcessed: string;
scrobbleEventType: ScrobbleEventType;
rating: number | null;
processedDateUtc: string;
lastModified: string;
created: string;
volumeNumber: number | null;
chapterNumber: number | null;
}

View file

@ -0,0 +1,6 @@
export interface ScrobbleHold {
seriesId: number;
libraryId: number;
seriesName: string;
createdUtc: string;
}

View file

@ -0,0 +1,6 @@
export interface ExternalSeries {
name: string;
coverUrl: string;
url: string;
summary: string;
}

View file

@ -0,0 +1,7 @@
import {Series} from "../series";
import {ExternalSeries} from "./external-series";
export interface Recommendation {
ownedSeries: Array<Series>;
externalSeries: Array<ExternalSeries>;
}

View file

@ -27,10 +27,6 @@ export interface Series {
* User's rating (0-5)
*/
userRating: number;
/**
* The user's review
*/
userReview: string;
libraryId: number;
/**
* DateTime the entity was created
@ -63,4 +59,8 @@ export interface Series {
* Highest level folder containing this series
*/
folderPath: string;
/**
* This is currently only used on Series detail page for recommendations
*/
summary?: string;
}

View file

@ -1,7 +1,7 @@
import { AgeRestriction } from './metadata/age-restriction';
import { Preferences } from './preferences/preferences';
// This interface is only used for login and storing/retreiving JWT from local storage
// This interface is only used for login and storing/retrieving JWT from local storage
export interface User {
username: string;
token: string;
@ -11,4 +11,4 @@ export interface User {
apiKey: string;
email: string;
ageRestriction: AgeRestriction;
}
}

View file

@ -1,7 +1,7 @@
import { HttpClient } 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 {DestroyRef, inject, Injectable } from '@angular/core';
import {catchError, of, ReplaySubject, throwError} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences';
import { User } from '../_models/user';
@ -33,11 +33,17 @@ export class AccountService {
baseUrl = environment.apiUrl;
userKey = 'kavita-user';
public lastLoginKey = 'kavita-lastlogin';
currentUser: User | undefined;
private currentUser: User | undefined;
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User | undefined>(1);
currentUser$ = this.currentUserSource.asObservable();
public currentUser$ = this.currentUserSource.asObservable();
private hasValidLicenseSource = new ReplaySubject<boolean>(1);
/**
* Does the user have an active license
*/
public hasValidLicense$ = this.hasValidLicenseSource.asObservable();
/**
* SetTimeout handler for keeping track of refresh token call
@ -48,8 +54,9 @@ export class AccountService {
private messageHub: MessageHubService, private themeService: ThemeService) {
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
map(evt => evt.payload as UserUpdateEvent),
tap(u => console.log('user update: ', u)),
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshToken()))
switchMap(() => this.refreshAccount()))
.subscribe(() => {});
}
@ -77,6 +84,36 @@ export class AccountService {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
}
deleteLicense() {
return this.httpClient.delete<string>(this.baseUrl + 'license', TextResonse);
}
hasValidLicense(forceCheck: boolean = false) {
return this.httpClient.get<string>(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse)
.pipe(
map(res => res === "true"),
tap(res => {
this.hasValidLicenseSource.next(res)
}),
catchError(error => {
this.hasValidLicenseSource.next(false);
return throwError(error); // Rethrow the error to propagate it further
})
);
}
hasAnyLicense() {
return this.httpClient.get<string>(this.baseUrl + 'license/has-license', TextResonse)
.pipe(
map(res => res === "true"),
);
}
updateUserLicense(license: string, email: string) {
return this.httpClient.post<string>(this.baseUrl + 'license', {license, email}, TextResonse)
.pipe(map(res => res === "true"));
}
login(model: {username: string, password: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
map((response: User) => {
@ -110,6 +147,12 @@ export class AccountService {
this.currentUser = user;
this.currentUserSource.next(user);
if (user) {
this.messageHub.createHubConnection(user, this.hasAdminRole(user));
}
this.hasValidLicense().subscribe();
this.stopRefreshTokenTimer();
if (this.currentUser !== undefined) {
@ -122,9 +165,9 @@ export class AccountService {
this.currentUserSource.next(undefined);
this.currentUser = undefined;
this.stopRefreshTokenTimer();
this.messageHub.stopHubConnection();
// Upon logout, perform redirection
this.router.navigateByUrl('/login');
this.messageHub.stopHubConnection();
}
@ -173,6 +216,7 @@ export class AccountService {
/**
* Given a user id, returns a full url for setting up the user account
* @param userId
* @param withBaseUrl Should base url be included in invite url
* @returns
*/
getInviteUrl(userId: number, withBaseUrl: boolean = true) {
@ -213,7 +257,7 @@ export class AccountService {
*/
getPreferences() {
return this.httpClient.get<Preferences>(this.baseUrl + 'users/get-preferences').pipe(map(pref => {
if (this.currentUser !== undefined || this.currentUser != null) {
if (this.currentUser !== undefined && this.currentUser !== null) {
this.currentUser.preferences = pref;
this.setCurrentUser(this.currentUser);
}
@ -223,7 +267,7 @@ export class AccountService {
updatePreferences(userPreferences: Preferences) {
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
if (this.currentUser !== undefined || this.currentUser != null) {
if (this.currentUser !== undefined && this.currentUser !== null) {
this.currentUser.preferences = settings;
this.setCurrentUser(this.currentUser);
}
@ -237,7 +281,7 @@ export class AccountService {
if (userString) {
return JSON.parse(userString)
};
}
return undefined;
}
@ -257,6 +301,25 @@ export class AccountService {
}));
}
getOpdsUrl() {
return this.httpClient.get<string>(this.baseUrl + 'account/opds-url', TextResonse);
}
private refreshAccount() {
console.log('Refreshing account');
if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.get<User>(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => {
if (user) {
this.currentUser = {...user};
}
this.setCurrentUser(this.currentUser);
return user;
}));
}
private refreshToken() {
if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
@ -291,6 +354,4 @@ export class AccountService {
}
}
}

View file

@ -24,12 +24,13 @@ export class LibraryService {
if (this.libraryNames != undefined) {
return of(this.libraryNames);
}
return this.httpClient.get<Library[]>(this.baseUrl + 'library').pipe(map(l => {
return this.httpClient.get<Library[]>(this.baseUrl + 'library').pipe(map(libraries => {
this.libraryNames = {};
l.forEach(lib => {
libraries.forEach(lib => {
if (this.libraryNames !== undefined) {
this.libraryNames[lib.id] = lib.name;
}
}
});
return this.libraryNames;
}));
@ -44,7 +45,7 @@ export class LibraryService {
l.forEach(lib => {
if (this.libraryNames !== undefined) {
this.libraryNames[lib.id] = lib.name;
}
}
});
return this.libraryNames[libraryId];
}));

View file

@ -80,6 +80,10 @@ export enum EVENTS {
* A user is sending files to their device
*/
SendingToDevice = 'SendingToDevice',
/**
* A scrobbling token has expired
*/
ScrobblingKeyExpired = 'ScrobblingKeyExpired',
}
export interface Message<T> {
@ -110,9 +114,7 @@ export class MessageHubService {
isAdmin: boolean = false;
constructor(private toastr: ToastrService, private router: Router) {
}
constructor() {}
/**
* Tests that an event is of the type passed
@ -265,6 +267,13 @@ export class MessageHubService {
payload: resp.body
});
});
this.hubConnection.on(EVENTS.ScrobblingKeyExpired, resp => {
this.messagesSource.next({
event: EVENTS.ScrobblingKeyExpired,
payload: resp.body
});
});
}
stopHubConnection() {

View file

@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import {map, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { Genre } from '../_models/metadata/genre';
@ -86,7 +86,8 @@ export class MetadataService {
if (this.validLanguages != undefined && this.validLanguages.length > 0) {
return of(this.validLanguages);
}
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages').pipe(map(l => this.validLanguages = l));
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages')
.pipe(tap(l => this.validLanguages = l));
}
getAllPeople(libraries?: Array<number>) {

View file

@ -0,0 +1,94 @@
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 { 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 {ScrobbleEventFilter} from "../_models/scrobbling/scrobble-event-filter";
import {UtilityService} from "../shared/_services/utility.service";
import {ReadingList} from "../_models/reading-list";
export enum ScrobbleProvider {
AniList= 1,
Mal = 2
}
@Injectable({
providedIn: 'root'
})
export class ScrobblingService {
private readonly destroyRef = inject(DestroyRef);
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient, private utilityService: UtilityService) {}
hasTokenExpired(provider: ScrobbleProvider) {
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/token-expired?provider=' + provider, TextResonse)
.pipe(map(r => r === "true"));
}
updateAniListToken(token: string) {
return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token});
}
getAniListToken() {
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/anilist-token', TextResonse);
}
getScrobbleErrors() {
return this.httpClient.get<Array<ScrobbleError>>(this.baseUrl + 'scrobbling/scrobble-errors');
}
getScrobbleEvents(filter: ScrobbleEventFilter, pageNum: number | undefined = undefined, itemsPerPage: number | undefined = undefined) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<ScrobbleEvent[]>>(this.baseUrl + 'scrobbling/scrobble-events', filter, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<ScrobbleEvent[]>());
})
);
}
clearScrobbleErrors() {
return this.httpClient.post(this.baseUrl + 'scrobbling/clear-errors', {});
}
getHolds() {
return this.httpClient.get<Array<ScrobbleHold>>(this.baseUrl + 'scrobbling/holds');
}
libraryAllowsScrobbling(seriesId: number) {
return this.httpClient.get(this.baseUrl + 'scrobbling/library-allows-scrobbling?seriesId=' + seriesId, TextResonse)
.pipe(map(res => res === "true"));
}
hasHold(seriesId: number) {
return this.httpClient.get(this.baseUrl + 'scrobbling/has-hold?seriesId=' + seriesId, TextResonse)
.pipe(map(res => res === "true"));
}
addHold(seriesId: number) {
return this.httpClient.post(this.baseUrl + 'scrobbling/add-hold?seriesId=' + seriesId, TextResonse);
}
removeHold(seriesId: number) {
return this.httpClient.delete(this.baseUrl + 'scrobbling/remove-hold?seriesId=' + seriesId, TextResonse);
}
}

View file

@ -18,6 +18,9 @@ import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response';
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
@Injectable({
providedIn: 'root'
@ -83,8 +86,8 @@ export class SeriesService {
return this.httpClient.post<boolean>(this.baseUrl + 'series/delete-multiple', {seriesIds});
}
updateRating(seriesId: number, userRating: number, userReview: string) {
return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating, userReview});
updateRating(seriesId: number, userRating: number) {
return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating});
}
updateSeries(model: any) {
@ -190,6 +193,10 @@ export class SeriesService {
return this.httpClient.get<RelatedSeries>(this.baseUrl + 'series/all-related?seriesId=' + seriesId);
}
getRecommendationsForSeries(seriesId: number) {
return this.httpClient.get<Recommendation>(this.baseUrl + 'recommended/recommendations?seriesId=' + seriesId);
}
updateRelationships(seriesId: number, adaptations: Array<number>, characters: Array<number>,
contains: Array<number>, others: Array<number>, prequels: Array<number>,
sequels: Array<number>, sideStories: Array<number>, spinOffs: Array<number>,
@ -202,4 +209,18 @@ export class SeriesService {
getSeriesDetail(seriesId: number) {
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
}
getReviews(seriesId: number) {
return this.httpClient.get<Array<UserReview>>(this.baseUrl + 'review?seriesId=' + seriesId);
}
updateReview(seriesId: number, tagline: string, body: string) {
return this.httpClient.post<UserReview>(this.baseUrl + 'review', {
seriesId, tagline, body
});
}
getRatings(seriesId: number) {
return this.httpClient.get<Array<Rating>>(this.baseUrl + 'rating?seriesId=' + seriesId);
}
}

View file

@ -15,9 +15,6 @@ export class ServerService {
constructor(private httpClient: HttpClient) { }
restart() {
return this.httpClient.post(this.baseUrl + 'server/restart', {});
}
getServerInfo() {
return this.httpClient.get<ServerInfo>(this.baseUrl + 'server/server-info');
@ -59,6 +56,10 @@ export class ServerService {
return this.httpClient.post(this.baseUrl + 'server/convert-media', {});
}
bustCache() {
return this.httpClient.post(this.baseUrl + 'server/bust-review-and-rec-cache', {});
}
getMediaErrors() {
return this.httpClient.get<Array<KavitaMediaError>>(this.baseUrl + 'server/media-errors', {});
}

View file

@ -5,14 +5,13 @@ import {
inject,
Inject,
Injectable,
OnDestroy,
Renderer2,
RendererFactory2,
SecurityContext
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { ToastrService } from 'ngx-toastr';
import { map, ReplaySubject, Subject, takeUntil, take } from 'rxjs';
import { map, ReplaySubject, take } from 'rxjs';
import { environment } from 'src/environments/environment';
import { ConfirmService } from '../shared/confirm.service';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
@ -47,11 +46,9 @@ export class ThemeService {
constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient,
messageHub: MessageHubService, private domSantizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) {
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) {
this.renderer = rendererFactory.createRenderer(null, null);
this.getThemes();
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
if (message.event !== EVENTS.NotificationProgress) return;
@ -172,12 +169,12 @@ export class ThemeService {
}
const tileColor = this.getTileColor();
if (themeColor) {
if (tileColor) {
this.document.querySelector('meta[name="msapplication-TileColor"]')?.setAttribute('content', themeColor);
}
const colorScheme = this.getColorScheme();
if (themeColor) {
if (colorScheme) {
this.document.querySelector('body')?.setAttribute('theme', colorScheme);
}
@ -201,7 +198,7 @@ export class ThemeService {
private fetchThemeContent(themeId: number) {
return this.httpClient.get<string>(this.baseUrl + 'theme/download-content?themeId=' + themeId, TextResonse).pipe(map(encodedCss => {
return this.domSantizer.sanitize(SecurityContext.STYLE, encodedCss);
return this.domSanitizer.sanitize(SecurityContext.STYLE, encodedCss);
}));
}

View file

@ -0,0 +1,32 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">KavitaPlus Features</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<h5>Current Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">Scrobble Support</li>
<li class="list-group-item">Series Recommendations</li>
<li class="list-group-item">Series Reviews</li>
<li class="list-group-item">Remove Donation on Side nav</li>
</ul>
<h5>Planned Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">More external data providers</li>
<li class="list-group-item">Webhooks</li>
<li class="list-group-item">Kobo Progress Syncing</li>
<li class="list-group-item">Trending/External rating integration</li>
<li class="list-group-item">Your ideas upvoted via FeatHub</li>
</ul>
<div class="text-muted">These feature unlock for the whole server while subscription active</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View file

@ -0,0 +1,20 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-feature-list-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './feature-list-modal.component.html',
styleUrls: ['./feature-list-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FeatureListModalComponent {
constructor(private modal: NgbActiveModal) {}
close() {
this.modal.close();
}
}

View file

@ -0,0 +1,18 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{review.username + "'s"}} Review {{review.isExternal ? '(external)' : ''}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
</div>
<div class="modal-footer">
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
Go To Review
</a>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View file

@ -0,0 +1,14 @@
.img-max-width {
hr {
border: solid 2px rgba(var(--primary-color), 0.5) !important;
}
img {
max-width: 100%;
max-height: 800px;
}
h1 {
font-size: 1.5rem;
}
}

View file

@ -0,0 +1,54 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
Inject,
Input, ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import {CommonModule, DOCUMENT} from '@angular/common';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {ReactiveFormsModule} from "@angular/forms";
import {UserReview} from "../review-card/user-review";
import {SpoilerComponent} from "../spoiler/spoiler.component";
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
@Component({
selector: 'app-review-card-modal',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe],
templateUrl: './review-card-modal.component.html',
styleUrls: ['./review-card-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class ReviewCardModalComponent implements AfterViewInit {
@Input({required: true}) review!: UserReview;
@ViewChild('container', { read: ViewContainerRef }) container!: ViewContainerRef;
constructor(private modal: NgbActiveModal, @Inject(DOCUMENT) private document: Document) {
}
close() {
this.modal.close();
}
ngAfterViewInit() {
const spoilers = this.document.querySelectorAll('span.spoiler');
for (let i = 0; i < spoilers.length; i++) {
const spoiler = spoilers[i];
const componentRef = this.container.createComponent<SpoilerComponent>(SpoilerComponent);
componentRef.instance.html = spoiler.innerHTML;
if (spoiler.parentNode != null) {
spoiler.parentNode.replaceChild(componentRef.location.nativeElement, spoiler);
}
componentRef.instance.cdRef.markForCheck();
}
}
}

View file

@ -0,0 +1,29 @@
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div *ngIf="isMyReview" class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" title="This is your review"></i>
<span class="visually-hidden">This is your review</span>
</div>
</div>
<div class="col-md-10">
<div class="card-body">
<h6 class="card-title" [title]="review.tagline">
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
<ng-template #noTagline>
{{review.isExternal ? 'External Review' : 'Review'}}
</ng-template>
</h6>
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
</p>
</div>
</div>
<div class="card-footer bg-transparent text-muted">
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span>
</div>
</div>
</div>

View file

@ -0,0 +1,46 @@
.profile-image {
font-size: 2rem;
padding: 20px;
}
.my-review {
position: absolute;
z-index: 20;
top: 38px;
left: 38px;
color: var(--review-card-star-color);
}
.card-text {
font-size: 14px;
}
.card-title {
overflow: hidden;
width: 235px;
word-break: break-all;
height: 20px;
}
.card-text.no-images {
min-height: 63px;
max-height: 63px;
text-overflow: ellipsis;
overflow: hidden;
}
.card-footer {
width: 288px;
}
.card {
cursor: pointer;
}
.no-images img {
display: none;
}
.card-footer {
font-size: 13px
}

View file

@ -0,0 +1,47 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {UserReview} from "./user-review";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component";
import {AccountService} from "../../_services/account.service";
import {ReviewSeriesModalComponent} from "../review-series-modal/review-series-modal.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
@Component({
selector: 'app-review-card',
standalone: true,
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe],
templateUrl: './review-card.component.html',
styleUrls: ['./review-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewCardComponent implements OnInit {
@Input({required: true}) review!: UserReview;
private readonly accountService = inject(AccountService);
isMyReview: boolean = false;
constructor(private readonly modalService: NgbModal, private readonly cdRef: ChangeDetectorRef) {}
ngOnInit() {
this.accountService.currentUser$.subscribe(u => {
if (u) {
this.isMyReview = this.review.username === u.username;
this.cdRef.markForCheck();
}
});
}
showModal() {
let component;
if (this.isMyReview) {
component = ReviewSeriesModalComponent;
} else {
component = ReviewCardModalComponent;
}
const ref = this.modalService.open(component, {size: "lg"});
ref.componentInstance.review = this.review;
}
}

View file

@ -0,0 +1,11 @@
export interface UserReview {
seriesId: number;
libraryId: number;
score: number;
username: string;
body: string;
tagline?: string;
isExternal: boolean;
bodyJustText?: string;
externalUrl?: string;
}

View file

@ -1,27 +1,23 @@
<div *ngIf="series !== undefined">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{series.name}} Review</h4>
<h4 class="modal-title" id="modal-basic-title">Edit Review</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="row g-0">
<label for="rating" class="form-label">Rating</label>
<div>
<ngb-rating style="margin-top: 2px; font-size: 1.5rem;" formControlName="rating"></ngb-rating>
<button class="btn btn-icon ms-2" (click)="clearRating()" title="clear"><i aria-hidden="true" class="fa fa-ban"></i></button>
</div>
<label for="tagline" class="form-label">Tagline</label>
<input id="tagline" class="form-control" formControlName="tagline" />
</div>
<div class="row g-0">
<div class="row g-0 mt-2">
<label for="review" class="form-label">Review</label>
<textarea id="review" class="form-control" formControlName="review" rows="3"></textarea>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="close()">Close</button>

View file

@ -1,26 +1,29 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Series } from 'src/app/_models/series';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap';
import { SeriesService } from 'src/app/_services/series.service';
import {UserReview} from "../review-card/user-review";
import {CommonModule} from "@angular/common";
@Component({
selector: 'app-review-series-modal',
standalone: true,
imports: [CommonModule, NgbRating, ReactiveFormsModule],
templateUrl: './review-series-modal.component.html',
styleUrls: ['./review-series-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewSeriesModalComponent implements OnInit {
@Input({required: true}) series!: Series;
@Input({required: true}) review!: UserReview;
reviewGroup!: FormGroup;
constructor(public modal: NgbActiveModal, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.reviewGroup = new FormGroup({
review: new FormControl(this.series.userReview, []),
rating: new FormControl(this.series.userRating, [])
tagline: new FormControl(this.review.tagline || '', [Validators.min(20), Validators.max(120)]),
reviewBody: new FormControl(this.review.body, [Validators.min(20)]),
});
this.cdRef.markForCheck();
}
@ -29,15 +32,10 @@ export class ReviewSeriesModalComponent implements OnInit {
this.modal.close({success: false, review: null});
}
clearRating() {
this.reviewGroup.get('rating')?.setValue(0);
this.cdRef.markForCheck();
}
save() {
const model = this.reviewGroup.value;
this.seriesService.updateRating(this.series?.id, model.rating, model.review).subscribe(() => {
this.modal.close({success: true, review: model.review, rating: model.rating});
this.seriesService.updateReview(this.review.seriesId, model.tagline, model.reviewBody).subscribe(() => {
this.modal.close({success: true});
});
}
}

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event";
@Pipe({
name: 'scrobbleEventType',
standalone: true
})
export class ScrobbleEventTypePipe implements PipeTransform {
transform(value: ScrobbleEventType): string {
switch (value) {
case ScrobbleEventType.ChapterRead: return 'Reading Progress';
case ScrobbleEventType.ScoreUpdated: return 'Rating Update';
case ScrobbleEventType.AddWantToRead: return 'Want To Read: Add';
case ScrobbleEventType.RemoveWantToRead: return 'Want To Read: Remove';
case ScrobbleEventType.Review: return 'Review update';
}
}
}

View file

@ -0,0 +1,7 @@
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
<span *ngIf="isCollapsed; else show">Spoiler, click to show</span>
<ng-template #show>
<div [innerHTML]="html | safeHtml"></div>
</ng-template>
</div>

View file

@ -0,0 +1,7 @@
.spoiler {
background-color: var(--review-spoiler-bg-color);
color: var(--review-spoiler-text-color);
cursor: pointer;
}

View file

@ -0,0 +1,44 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
OnInit,
ViewEncapsulation
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
@Component({
selector: 'app-spoiler',
standalone: true,
imports: [CommonModule, SafeHtmlPipe],
templateUrl: './spoiler.component.html',
styleUrls: ['./spoiler.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SpoilerComponent implements OnInit{
@Input({required: true}) html!: string;
isCollapsed: boolean = true;
public readonly cdRef = inject(ChangeDetectorRef);
constructor() {
this.isCollapsed = true;
this.cdRef.markForCheck();
}
ngOnInit() {
this.isCollapsed = true;
this.cdRef.markForCheck();
console.log('html: ', this.html)
}
toggle() {
this.isCollapsed = !this.isCollapsed;
this.cdRef.markForCheck();
}
}

View file

@ -11,12 +11,13 @@ export interface SortEvent<T> {
}
@Directive({
selector: 'th[sortable]',
host: {
'[class.asc]': 'direction === "asc"',
'[class.desc]': 'direction === "desc"',
'(click)': 'rotate()',
},
selector: 'th[sortable]',
host: {
'[class.asc]': 'direction === "asc"',
'[class.desc]': 'direction === "desc"',
'(click)': 'rotate()',
},
standalone: true,
})
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export class SortableHeader<T> {

View file

@ -1,18 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SortableHeader } from './_directives/sortable-header.directive';
@NgModule({
declarations: [
SortableHeader
],
imports: [
CommonModule
],
exports: [
SortableHeader
]
})
export class TableModule { }

View file

@ -0,0 +1,84 @@
<h5>Scrobble History</h5>
<p>Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active
scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it
is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">Filter</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="created" (sort)="updateSort($event)">
Created
</th>
<th scope="col" sortable="lastModified" (sort)="updateSort($event)" direction="desc">
Last Modified
</th>
<th scope="col">
Type
</th>
<th scope="col" sortable="seriesName" (sort)="updateSort($event)">
Series
</th>
<th scope="col">
Data
</th>
<th scope="col">
Is Processed
</th>
</tr>
</thead>
<tbody>
<tr *ngIf="events.length === 0">
<td colspan="6">No Data</td>
</tr>
<tr *ngFor="let item of events; let idx = index;">
<td>
{{item.created | date:'MM/dd/yy h:mm a' }}
</td>
<td>
{{item.lastModified | date:'MM/dd/yy h:mm a' }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
<ng-container [ngSwitch]="item.scrobbleEventType">
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
Volume {{item.volumeNumber}} Chapter {{item.chapterNumber}}
</ng-container>
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
Rating {{item.rating}}
</ng-container>
<ng-container *ngSwitchDefault>
Not Applicable
</ng-container>
</ng-container>
</td>
<td>
<i class="fa-regular fa-circle icon" aria-hidden="true" *ngIf="!item.isProcessed"></i>
<i class="fa-solid fa-check-circle icon" aria-hidden="true" *ngIf="item.isProcessed"></i>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">{{item.isProcessed ? 'Processed' : 'Not Processed'}}</span>
</td>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,3 @@
.icon {
color: var(--primary-color);
}

View file

@ -0,0 +1,93 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ScrobblingService} from "../../_services/scrobbling.service";
import {shareReplay} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
import {NgbPagination} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
import {debounceTime, map, take, tap} from "rxjs/operators";
import {PaginatedResult, Pagination} from "../../_models/pagination";
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserScrobbleHistoryComponent implements OnInit {
private readonly scrobbleService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
pagination: Pagination | undefined;
events: Array<ScrobbleEvent> = [];
formGroup: FormGroup = new FormGroup({
'filter': new FormControl('', [])
});
get ScrobbleEventType() { return ScrobbleEventType; }
ngOnInit() {
this.loadPage({column: 'created', direction: 'desc'});
this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
this.loadPage();
})
}
onPageChange(pageNum: number) {
let prevPage = 0;
if (this.pagination) {
prevPage = this.pagination.currentPage;
this.pagination.currentPage = pageNum;
}
if (prevPage !== pageNum) {
this.loadPage();
}
}
updateSort(sortEvent: SortEvent<ScrobbleEvent>) {
this.loadPage(sortEvent);
}
loadPage(sortEvent?: SortEvent<ScrobbleEvent>) {
if (sortEvent && this.pagination) {
this.pagination.currentPage = 1;
this.cdRef.markForCheck();
}
const page = this.pagination?.currentPage || 0;
const pageSize = this.pagination?.itemsPerPage || 0;
const isDescending = sortEvent?.direction === 'desc';
const field = this.mapSortColumnField(sortEvent?.column);
const query = this.formGroup.get('filter')?.value;
this.scrobbleService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
.pipe(take(1))
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
this.events = result.result;
this.pagination = result.pagination;
this.cdRef.markForCheck();
});
}
private mapSortColumnField(column: string | undefined) {
switch (column) {
case 'created': return ScrobbleEventSortField.Created;
case 'isProcessed': return ScrobbleEventSortField.IsProcessed;
case 'lastModified': return ScrobbleEventSortField.LastModified;
case 'seriesName': return ScrobbleEventSortField.Series;
}
return ScrobbleEventSortField.None;
}
}

View file

@ -5,7 +5,7 @@
<div class="modal-body">
<div class="mb-3">
<label for="filter" class="form-label">Path</label>
<label for="typeahead-focus" class="form-label">Path</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
@ -20,8 +20,8 @@
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
*ngFor="let route of routeStack.items; let index = index">
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
*ngFor="let route of routeStack.items; let index = index; let last = last;">
<ng-container *ngIf="last; else nonActive">
{{route}}
</ng-container>
<ng-template #nonActive>

View file

@ -1,9 +1,11 @@
import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { NgbActiveModal, NgbTypeahead, NgbHighlight } from '@ng-bootstrap/ng-bootstrap';
import { catchError, debounceTime, distinctUntilChanged, filter, map, merge, Observable, of, OperatorFunction, Subject, switchMap, tap } from 'rxjs';
import { Stack } from 'src/app/shared/data-structures/stack';
import { DirectoryDto } from 'src/app/_models/system/directory-dto';
import { LibraryService } from '../../../_services/library.service';
import { NgIf, NgFor, NgClass } from '@angular/common';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
export interface DirectoryPickerResult {
@ -14,9 +16,11 @@ export interface DirectoryPickerResult {
@Component({
selector: 'app-directory-picker',
templateUrl: './directory-picker.component.html',
styleUrls: ['./directory-picker.component.scss']
selector: 'app-directory-picker',
templateUrl: './directory-picker.component.html',
styleUrls: ['./directory-picker.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass]
})
export class DirectoryPickerComponent implements OnInit {

View file

@ -1,15 +1,19 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/auth/member';
import { LibraryService } from 'src/app/_services/library.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {Library} from 'src/app/_models/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import {NgFor, NgIf} from '@angular/common';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
// TODO: Change to OnPush
@Component({
selector: 'app-library-access-modal',
templateUrl: './library-access-modal.component.html',
styleUrls: ['./library-access-modal.component.scss']
styleUrls: ['./library-access-modal.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibraryAccessModalComponent implements OnInit {
@ -19,6 +23,8 @@ export class LibraryAccessModalComponent implements OnInit {
selections!: SelectionModel<Library>;
selectAll: boolean = false;
cdRef = inject(ChangeDetectorRef);
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
@ -49,7 +55,7 @@ export class LibraryAccessModalComponent implements OnInit {
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.allLibraries);
// If a member is passed in, then auto-select their libraries
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {
@ -57,6 +63,7 @@ export class LibraryAccessModalComponent implements OnInit {
});
this.selectAll = this.selections.selected().length === this.allLibraries.length;
}
this.cdRef.markForCheck();
}
reset() {
@ -66,6 +73,7 @@ export class LibraryAccessModalComponent implements OnInit {
toggleAll() {
this.selectAll = !this.selectAll;
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
this.cdRef.markForCheck();
}
handleSelection(item: Library) {
@ -76,6 +84,7 @@ export class LibraryAccessModalComponent implements OnInit {
} else if (numberOfSelected == this.selectedLibraries.length) {
this.selectAll = true;
}
this.cdRef.markForCheck();
}
}

View file

@ -1,13 +1,17 @@
import { Component, Input } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/auth/member';
import { AccountService } from 'src/app/_services/account.service';
import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-reset-password-modal',
templateUrl: './reset-password-modal.component.html',
styleUrls: ['./reset-password-modal.component.scss']
selector: 'app-reset-password-modal',
templateUrl: './reset-password-modal.component.html',
styleUrls: ['./reset-password-modal.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, SentenceCasePipe]
})
export class ResetPasswordModalComponent {

View file

@ -2,36 +2,75 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import {
NgbAccordionModule,
NgbCollapse,
NgbDropdownModule,
NgbNavModule,
NgbTooltipModule,
NgbTypeaheadModule
} from '@ng-bootstrap/ng-bootstrap';
import { ManageLibraryComponent } from './manage-library/manage-library.component';
import { ManageUsersComponent } from './manage-users/manage-users.component';
import { SharedModule } from '../shared/shared.module';
import { LibraryAccessModalComponent } from './_modals/library-access-modal/library-access-modal.component';
import { DirectoryPickerComponent } from './_modals/directory-picker/directory-picker.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
import { ManageSettingsComponent } from './manage-settings/manage-settings.component';
import { ManageSystemComponent } from './manage-system/manage-system.component';
import { PipeModule } from '../pipe/pipe.module';
import { InviteUserComponent } from './invite-user/invite-user.component';
import { RoleSelectorComponent } from './role-selector/role-selector.component';
import { LibrarySelectorComponent } from './library-selector/library-selector.component';
import { EditUserComponent } from './edit-user/edit-user.component';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { SidenavModule } from '../sidenav/sidenav.module';
import { ManageMediaSettingsComponent } from './manage-media-settings/manage-media-settings.component';
import { ManageEmailSettingsComponent } from './manage-email-settings/manage-email-settings.component';
import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component';
import { ManageLogsComponent } from './manage-logs/manage-logs.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { StatisticsModule } from '../statistics/statistics.module';
import { ManageAlertsComponent } from './manage-alerts/manage-alerts.component';
import {ManageScrobbleErrorsComponent} from "./manage-scrobble-errors/manage-scrobble-errors.component";
import {DefaultValuePipe} from "../pipe/default-value.pipe";
import {LibraryTypePipe} from "../pipe/library-type.pipe";
import {TimeAgoPipe} from "../pipe/time-ago.pipe";
import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
import {FilterPipe} from "../pipe/filter.pipe";
import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {RouterModule} from "@angular/router";
import {LicenseComponent} from "./license/license.component";
@NgModule({
declarations: [
imports: [
CommonModule,
AdminRoutingModule,
ReactiveFormsModule,
RouterModule,
FormsModule,
NgbNavModule,
NgbTooltipModule,
NgbTypeaheadModule,
NgbDropdownModule,
NgbAccordionModule,
UserSettingsModule,
VirtualScrollerModule,
ManageScrobbleErrorsComponent,
DefaultValuePipe,
LibraryTypePipe,
TimeAgoPipe,
SentenceCasePipe,
FilterPipe,
TagBadgeComponent,
LoadingComponent,
SideNavCompanionBarComponent,
NgbCollapse,
ManageUsersComponent,
DashboardComponent,
ManageLibraryComponent,
@ -49,25 +88,8 @@ import { ManageAlertsComponent } from './manage-alerts/manage-alerts.component';
ManageTasksSettingsComponent,
ManageLogsComponent,
ManageAlertsComponent,
],
imports: [
CommonModule,
AdminRoutingModule,
ReactiveFormsModule,
FormsModule,
NgbNavModule,
NgbTooltipModule,
NgbTypeaheadModule, // Directory Picker
NgbDropdownModule,
NgbAccordionModule,
SharedModule,
PipeModule,
SidenavModule,
UserSettingsModule, // API-key componet
VirtualScrollerModule,
StatisticsModule
],
providers: []
LicenseComponent
],
providers: []
})
export class AdminModule { }

View file

@ -5,7 +5,7 @@
</app-side-nav-companion-bar>
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class=tab>
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === TabID.General">
@ -35,12 +35,16 @@
<ng-container *ngIf="tab.fragment === TabID.Tasks">
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today!</p>
<app-license></app-license>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Plugins">
Nothing here yet. This will be built out in a future update.
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
</div>
</div>

View file

@ -1,3 +1,10 @@
.container {
padding-top: 10px;
}
}
.tab:last-child > a {
&.active, &::before {
background-color: #FFBA15;
}
}

View file

@ -1,9 +1,23 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ServerService } from 'src/app/_services/server.service';
import { Title } from '@angular/platform-browser';
import { NavService } from '../../_services/nav.service';
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
import { LicenseComponent } from '../license/license.component';
import { ManageTasksSettingsComponent } from '../manage-tasks-settings/manage-tasks-settings.component';
import { ServerStatsComponent } from '../../statistics/_components/server-stats/server-stats.component';
import { ManageSystemComponent } from '../manage-system/manage-system.component';
import { ManageLogsComponent } from '../manage-logs/manage-logs.component';
import { ManageLibraryComponent } from '../manage-library/manage-library.component';
import { ManageUsersComponent } from '../manage-users/manage-users.component';
import { ManageMediaSettingsComponent } from '../manage-media-settings/manage-media-settings.component';
import { ManageEmailSettingsComponent } from '../manage-email-settings/manage-email-settings.component';
import { ManageSettingsComponent } from '../manage-settings/manage-settings.component';
import { NgFor, NgIf } from '@angular/common';
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
enum TabID {
General = '',
@ -16,13 +30,15 @@ enum TabID {
Tasks = 'tasks',
Logs = 'logs',
Statistics = 'statistics',
KavitaPlus = 'kavitaplus'
}
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, ManageUsersComponent, ManageLibraryComponent, ManageLogsComponent, ManageSystemComponent, ServerStatsComponent, ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe]
})
export class DashboardComponent implements OnInit {
@ -33,19 +49,18 @@ export class DashboardComponent implements OnInit {
//{title: 'Logs', fragment: TabID.Logs},
{title: 'Media', fragment: TabID.Media},
{title: 'Email', fragment: TabID.Email},
//{title: 'Plugins', fragment: TabID.Plugins},
{title: 'Tasks', fragment: TabID.Tasks},
{title: 'Statistics', fragment: TabID.Statistics},
{title: 'System', fragment: TabID.System},
{title: 'Kavita+', fragment: TabID.KavitaPlus},
];
counter = this.tabs.length + 1;
active = this.tabs[0];
get TabID() {
return TabID;
}
constructor(public route: ActivatedRoute, private serverService: ServerService,
constructor(public route: ActivatedRoute, private serverService: ServerService,
private toastr: ToastrService, private titleService: Title, public navService: NavService) {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
@ -61,10 +76,4 @@ export class DashboardComponent implements OnInit {
ngOnInit() {
this.titleService.setTitle('Kavita - Admin Dashboard');
}
restartServer() {
this.serverService.restart().subscribe(() => {
setTimeout(() => this.toastr.success('Please reload.'), 1000);
});
}
}

View file

@ -2,7 +2,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal">
@ -12,8 +12,9 @@
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<input id="username" class="form-control" formControlName="username" type="text"
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
</div>
@ -23,8 +24,9 @@
<div class="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
<div id="email-validations" class="invalid-feedback"
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
This field is required
</div>

View file

@ -1,15 +1,22 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/auth/member';
import { AccountService } from 'src/app/_services/account.service';
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-edit-user',
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss']
selector: 'app-edit-user',
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe]
})
export class EditUserComponent implements OnInit {

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
@ -7,11 +7,18 @@ import { InviteUserResponse } from 'src/app/_models/auth/invite-user-response';
import { Library } from 'src/app/_models/library';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
import { ApiKeyComponent } from '../../user-settings/api-key/api-key.component';
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-invite-user',
templateUrl: './invite-user.component.html',
styleUrls: ['./invite-user.component.scss']
selector: 'app-invite-user',
templateUrl: './invite-user.component.html',
styleUrls: ['./invite-user.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent]
})
export class InviteUserComponent implements OnInit {

View file

@ -1,14 +1,17 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/auth/member';
import { LibraryService } from 'src/app/_services/library.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import { NgIf, NgFor } from '@angular/common';
@Component({
selector: 'app-library-selector',
templateUrl: './library-selector.component.html',
styleUrls: ['./library-selector.component.scss']
selector: 'app-library-selector',
templateUrl: './library-selector.component.html',
styleUrls: ['./library-selector.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor]
})
export class LibrarySelectorComponent implements OnInit {

View file

@ -0,0 +1,85 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-10">
<h4 id="license-key-header">Kavita+ License</h4>
</div>
<div class="col-2 text-end">
<ng-container *ngIf="hasLicense; else noLicense">
<ng-container *ngIf="hasValidLicense; else invalidLicenseBuy">
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">Manage</a>
</ng-container>
<ng-template #invalidLicenseBuy>
<a class="btn btn-primary btn-sm me-1"
ngbTooltip="If your subscription has ended, you must email support to get a new subscription created"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>Renew</a>
</ng-template>
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
<span *ngIf="!isChecking">Check</span>
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
</button>
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
<span *ngIf="!isViewMode">Cancel</span>
<span *ngIf="isViewMode">Edit</span>
</button>
</ng-container>
<ng-template #noLicense>
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">Buy</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Activate' : 'Cancel'}}</button>
</ng-template>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">
<ng-container *ngIf="hasLicense; else noToken">
<span class="me-1">*********</span>
<ng-container *ngIf="!isChecking; else checking">
<i *ngIf="hasValidLicense" ngbTooltip="License is valid" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">License is Valid</span>
</i>
<i class="error fa-solid fa-exclamation-circle ms-1" ngbTooltip="License Invalid" *ngIf="!hasValidLicense">
<span class="visually-hidden">License Not Valid</span>
</i>
</ng-container>
<ng-template #checking>
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</ng-template>
</ng-container>
<ng-template #noToken>No license key</ng-template>
</span>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<form [formGroup]="formGroup">
<p>Enter the License Key and Email used to register with Stripe</p>
<div class="form-group mb-3">
<label for="license-key">License Key</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">Email</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header" (click)="deleteLicense()">
Delete
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
<span *ngIf="!isSaving">Save</span>
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,7 @@
.error {
color: var(--error-color);
}
.successful-validation {
color: var(--primary-color);
}

View file

@ -0,0 +1,122 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
import {AccountService} from "../../_services/account.service";
import {ScrobblingService} from "../../_services/scrobbling.service";
import {ToastrService} from "ngx-toastr";
import {ConfirmService} from "../../shared/confirm.service";
import { LoadingComponent } from '../../shared/loading/loading.component';
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf } from '@angular/common';
import {environment} from "../../../environments/environment";
@Component({
selector: 'app-license',
templateUrl: './license.component.html',
styleUrls: ['./license.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule]
})
export class LicenseComponent implements OnInit {
formGroup: FormGroup = new FormGroup({});
isViewMode: boolean = true;
hasValidLicense: boolean = false;
hasLicense: boolean = false;
isChecking: boolean = false;
isSaving: boolean = false;
buyLink = environment.buyLink;
manageLink = environment.manageLink;
constructor(public accountService: AccountService, private scrobblingService: ScrobblingService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef,
private confirmService: ConfirmService) { }
ngOnInit(): void {
this.formGroup.addControl('licenseKey', new FormControl('', [Validators.required]));
this.formGroup.addControl('email', new FormControl('', [Validators.required]));
this.accountService.hasAnyLicense().subscribe(res => {
this.hasLicense = res;
this.cdRef.markForCheck();
});
this.accountService.hasValidLicense().subscribe(res => {
this.hasValidLicense = res;
this.cdRef.markForCheck();
});
}
resetForm() {
this.formGroup.get('licenseKey')?.setValue('');
this.formGroup.get('email')?.setValue('');
this.cdRef.markForCheck();
}
saveForm() {
this.isSaving = true;
this.cdRef.markForCheck();
this.accountService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim())
.subscribe(() => {
this.accountService.hasValidLicense(true).subscribe(isValid => {
this.hasValidLicense = isValid;
if (!this.hasValidLicense) {
this.toastr.info("License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.");
} else {
this.toastr.success('Kavita+ unlocked!');
}
this.hasLicense = this.formGroup.get('licenseKey')!.value.length > 0;
this.resetForm();
this.isViewMode = true;
this.isSaving = false;
this.cdRef.markForCheck();
});
}, err => {
if (err.hasOwnProperty('error')) {
this.toastr.error(JSON.parse(err['error'])['message']);
} else {
this.toastr.error("There was an error when activating your license. Please try again.");
}
this.isSaving = false;
this.cdRef.markForCheck();
});
}
async deleteLicense() {
if (!await this.confirmService.confirm('This will only delete Kavita\'s license key and allow a buy link to show. This will not cancel your subscription! Use this only if directed by support!')) {
return;
}
this.accountService.deleteLicense().subscribe(() => {
this.resetForm();
this.toggleViewMode();
this.validateLicense();
});
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
this.resetForm();
}
validateLicense() {
this.isChecking = true;
this.accountService.hasValidLicense(true).subscribe(res => {
this.hasValidLicense = res;
this.isChecking = false;
this.cdRef.markForCheck();
});
}
}

View file

@ -13,7 +13,7 @@
</div>
</div>
</form>
<table class="table table-light table-hover table-sm table-hover">
<table class="table table-striped table-hover table-sm table-hover">
<thead #header>
<tr>
<th scope="col"sortable="extension" (sort)="onSort($event)">

View file

@ -15,14 +15,19 @@ import { SortEvent, SortableHeader, compare } from 'src/app/_single-module/table
import { KavitaMediaError } from '../_models/media-error';
import { ServerService } from 'src/app/_services/server.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { FormControl, FormGroup } from '@angular/forms';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { FilterPipe } from '../../pipe/filter.pipe';
import { LoadingComponent } from '../../shared/loading/loading.component';
import { NgIf, NgFor } from '@angular/common';
@Component({
selector: 'app-manage-alerts',
templateUrl: './manage-alerts.component.html',
styleUrls: ['./manage-alerts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-manage-alerts',
templateUrl: './manage-alerts.component.html',
styleUrls: ['./manage-alerts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader]
})
export class ManageAlertsComponent implements OnInit {

View file

@ -1,14 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs';
import { SettingsService, EmailTestResult } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-manage-email-settings',
templateUrl: './manage-email-settings.component.html',
styleUrls: ['./manage-email-settings.component.scss']
selector: 'app-manage-email-settings',
templateUrl: './manage-email-settings.component.html',
styleUrls: ['./manage-email-settings.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet]
})
export class ManageEmailSettingsComponent implements OnInit {

View file

@ -6,7 +6,7 @@ import {
inject,
OnInit
} from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { distinctUntilChanged, filter, take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
@ -17,12 +17,19 @@ import { Library } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
import { TimeAgoPipe } from '../../pipe/time-ago.pipe';
import { LibraryTypePipe } from '../../pipe/library-type.pipe';
import { RouterLink } from '@angular/router';
import { NgFor, NgIf } from '@angular/common';
@Component({
selector: 'app-manage-library',
templateUrl: './manage-library.component.html',
styleUrls: ['./manage-library.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-manage-library',
templateUrl: './manage-library.component.html',
styleUrls: ['./manage-library.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgFor, RouterLink, NgbTooltip, NgIf, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe]
})
export class ManageLibraryComponent implements OnInit {
@ -82,7 +89,7 @@ export class ManageLibraryComponent implements OnInit {
getLibraries() {
this.loading = true;
this.cdRef.markForCheck();
this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => {
this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(libraries => {
this.libraries = [...libraries];
this.loading = false;
this.cdRef.markForCheck();

View file

@ -3,6 +3,8 @@ import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, ReplaySubject, Subject, take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { environment } from 'src/environments/environment';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { NgIf, NgFor, AsyncPipe, DatePipe } from '@angular/common';
interface LogMessage {
timestamp: string;
@ -12,9 +14,11 @@ interface LogMessage {
}
@Component({
selector: 'app-manage-logs',
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss']
selector: 'app-manage-logs',
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss'],
standalone: true,
imports: [NgIf, VirtualScrollerModule, NgFor, AsyncPipe, DatePipe]
})
export class ManageLogsComponent implements OnInit, OnDestroy {

View file

@ -37,14 +37,39 @@
</div>
</form>
<ngb-accordion #a="ngbAccordion" [destroyOnHide]="false">
<ngb-panel>
<ng-template ngbPanelTitle>
Media Issues <span class="ms-1" *ngIf="alertCount > 0">({{alertCount}})</span>
</ng-template>
<ng-template ngbPanelContent>
<app-manage-alerts (alertCount)="alertCount = $event"></app-manage-alerts>
</ng-template>
</ngb-panel>
</ngb-accordion>
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
Media Issues <span class="ms-1" *ngIf="alertCount > 0">({{alertCount}})</span>
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<app-manage-alerts (alertCount)="alertCount = $event"></app-manage-alerts>
</ng-template>
</div>
</div>
</div>
</div>
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
Scrobble Issues <span class="ms-1" *ngIf="scrobbleCount > 0">({{scrobbleCount}})</span>
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<app-manage-scrobble-errors (scrobbleCount)="scrobbleCount = $event"></app-manage-scrobble-errors>
</ng-template>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,17 +1,22 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs';
import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbTooltip, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody } from '@ng-bootstrap/ng-bootstrap';
import { EncodeFormats } from '../_models/encode-format';
import { ManageScrobbleErrorsComponent } from '../manage-scrobble-errors/manage-scrobble-errors.component';
import { ManageAlertsComponent } from '../manage-alerts/manage-alerts.component';
import { NgIf, NgTemplateOutlet, NgFor } from '@angular/common';
@Component({
selector: 'app-manage-media-settings',
templateUrl: './manage-media-settings.component.html',
styleUrls: ['./manage-media-settings.component.scss']
selector: 'app-manage-media-settings',
templateUrl: './manage-media-settings.component.html',
styleUrls: ['./manage-media-settings.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, NgFor, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, ManageAlertsComponent, ManageScrobbleErrorsComponent]
})
export class ManageMediaSettingsComponent implements OnInit {
@ -19,9 +24,10 @@ export class ManageMediaSettingsComponent implements OnInit {
settingsForm: FormGroup = new FormGroup({});
alertCount: number = 0;
scrobbleCount: number = 0;
get EncodeFormats() { return EncodeFormats; }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { }
ngOnInit(): void {

View file

@ -0,0 +1,56 @@
<p>This table contains issues found during scrobbling. This list is non-managed.
You can clear it at any time and wait for the next scrobble upload to see. If there is an unknown series, you are best correcting the
series name or localized series name or adding a weblink for the providers.</p>
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">Filter</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" placeholder="Filter" formControlName="filter" />
<button class="btn btn-primary" type="button" (click)="clear()">Clear Errors</button>
</div>
</div>
</div>
</form>
<table class="table table-striped table-hover table-sm table-hover">
<thead #header>
<tr>
<th scope="col" sortable="seriesId" (sort)="onSort($event)">
Series
</th>
<th scope="col" sortable="created" (sort)="onSort($event)">
Created
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
Comment
</th>
<th scope="col">
Edit
</th>
</tr>
</thead>
<tbody #container>
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
<ng-container *ngIf="data | filter: filterList as filteredData">
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
<tr *ngFor="let item of filteredData; index as i">
<td>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</td>
<td>
{{item.created | date:'shortDate'}}
</td>
<td>
{{item.comment}}
</td>
<td>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i>
<span class="visually-hidden">Edit {{item.details}}</span>
</button>
</td>
</tr>
</ng-container>
</tbody>
</table>

View file

@ -0,0 +1,3 @@
.primary-icon, button i.fa {
color: var(--primary-color);
}

View file

@ -0,0 +1,114 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit,
Output,
QueryList,
ViewChildren
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {compare, SortableHeader, SortEvent} from "../../_single-module/table/_directives/sortable-header.directive";
import {KavitaMediaError} from "../_models/media-error";
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {BehaviorSubject, filter, Observable, shareReplay} from "rxjs";
import {ScrobblingService} from "../../_services/scrobbling.service";
import {ScrobbleError} from "../../_models/scrobbling/scrobble-error";
import {SeriesService} from "../../_services/series.service";
import {EditSeriesModalComponent} from "../../cards/_modals/edit-series-modal/edit-series-modal.component";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {FilterPipe} from "../../pipe/filter.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({
selector: 'app-manage-scrobble-errors',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader],
templateUrl: './manage-scrobble-errors.component.html',
styleUrls: ['./manage-scrobble-errors.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageScrobbleErrorsComponent implements OnInit {
@Output() scrobbleCount = new EventEmitter<number>();
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>;
private readonly scrobbleService = inject(ScrobblingService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly seriesService = inject(SeriesService);
private readonly modalService = inject(NgbModal);
messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay());
currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'});
currentSort$: Observable<SortEvent<ScrobbleError>> = this.currentSort.asObservable();
data: Array<ScrobbleError> = [];
isLoading = true;
formGroup = new FormGroup({
filter: new FormControl('', [])
});
constructor() {}
ngOnInit() {
this.loadData();
this.messageHubUpdate$.subscribe(_ => this.loadData());
this.currentSort$.subscribe(sortConfig => {
this.data = (sortConfig.column) ? this.data.sort((a: ScrobbleError, b: ScrobbleError) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : this.data;
this.cdRef.markForCheck();
});
}
onSort(evt: any) {
//SortEvent<KavitaMediaError>
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
loadData() {
this.isLoading = true;
this.cdRef.markForCheck();
this.scrobbleService.getScrobbleErrors().subscribe(d => {
this.data = d;
this.isLoading = false;
this.scrobbleCount.emit(d.length);
this.cdRef.detectChanges();
});
}
clear() {
this.scrobbleService.clearScrobbleErrors().subscribe(_ => this.loadData());
}
filterList = (listItem: ScrobbleError) => {
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0;
}
editSeries(seriesId: number) {
this.seriesService.getSeries(seriesId).subscribe(series => {
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'xl' });
modalRef.componentInstance.series = series;
});
}
}

View file

@ -1,14 +1,8 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<div class="alert alert-warning" role="alert">
<strong>Notice:</strong> Changing Port or Base Url requires a manual restart of Kavita to take effect.
<strong>Notice:</strong> Changing Port, Base Url or IPs requires a manual restart of Kavita to take effect.
</div>
<!-- <div class="mb-3">
<label for="settings-cachedir" class="form-label">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 places temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
<span class="visually-hidden" id="settings-cachedir-help">Where the server places temporary files when reading. This will be cleaned up on a regular basis.</span>
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
</div> -->
<div class="mb-3">
<label for="settings-baseurl" class="form-label">Base Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>

View file

@ -1,18 +1,22 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, Validators, FormControl } from '@angular/forms';
import { FormGroup, Validators, FormControl, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { TagBadgeCursor } from 'src/app/shared/tag-badge/tag-badge.component';
import { ServerService } from 'src/app/_services/server.service';
import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, TitleCasePipe } from '@angular/common';
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
@Component({
selector: 'app-manage-settings',
templateUrl: './manage-settings.component.html',
styleUrls: ['./manage-settings.component.scss']
selector: 'app-manage-settings',
templateUrl: './manage-settings.component.html',
styleUrls: ['./manage-settings.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, TitleCasePipe]
})
export class ManageSettingsComponent implements OnInit {

View file

@ -14,28 +14,28 @@
<h3>More Info</h3>
<hr/>
<div>
<div class="row">
<div class="col-4">Home page:</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-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-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-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-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-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>
</div>

View file

@ -6,11 +6,14 @@ import { ServerService } from 'src/app/_services/server.service';
import { SettingsService } from '../settings.service';
import { ServerInfo } from '../_models/server-info';
import { ServerSettings } from '../_models/server-settings';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-manage-system',
templateUrl: './manage-system.component.html',
styleUrls: ['./manage-system.component.scss']
selector: 'app-manage-system',
templateUrl: './manage-system.component.html',
styleUrls: ['./manage-system.component.scss'],
standalone: true,
imports: [NgIf]
})
export class ManageSystemComponent implements OnInit {

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
@ -8,8 +8,10 @@ import { defer, forkJoin, Observable, of } from 'rxjs';
import { ServerService } from 'src/app/_services/server.service';
import { Job } from 'src/app/_models/job/job';
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { DefaultValuePipe } from '../../pipe/default-value.pipe';
import { NgIf, NgFor, AsyncPipe, TitleCasePipe, DatePipe } from '@angular/common';
interface AdhocTask {
name: string;
@ -20,9 +22,11 @@ interface AdhocTask {
}
@Component({
selector: 'app-manage-tasks-settings',
templateUrl: './manage-tasks-settings.component.html',
styleUrls: ['./manage-tasks-settings.component.scss']
selector: 'app-manage-tasks-settings',
templateUrl: './manage-tasks-settings.component.html',
styleUrls: ['./manage-tasks-settings.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe]
})
export class ManageTasksSettingsComponent implements OnInit {
@ -32,6 +36,7 @@ export class ManageTasksSettingsComponent implements OnInit {
logLevels: Array<string> = [];
recurringTasks$: Observable<Array<Job>> = of([]);
// noinspection JSVoidFunctionReturnValueUsed
adhocTasks: Array<AdhocTask> = [
{
name: 'Convert Media to Target Encoding',
@ -40,7 +45,13 @@ export class ManageTasksSettingsComponent implements OnInit {
successMessage: 'Conversion of Media to Target Encoding has been queued'
},
{
name: 'Clear Cache',
name: 'Bust Cache',
description: 'Busts the Kavita+ Cache - should only be used when debugging bad matches.',
api: this.serverService.bustCache(),
successMessage: 'Kavita+ Cache busted'
},
{
name: 'Clear Reading Cache',
description: 'Clears cached files for reading. Useful when you\'ve just updated a file that you were previously reading within the last 24 hours.',
api: this.serverService.clearCache(),
successMessage: 'Cache has been cleared'
@ -59,13 +70,13 @@ export class ManageTasksSettingsComponent implements OnInit {
},
{
name: 'Download Logs',
description: 'Compiles all log files into a zip and downloads it',
description: 'Compiles all log files into a zip and downloads it.',
api: defer(() => of(this.downloadService.download('logs', undefined))),
successMessage: ''
},
{
name: 'Analyze Files',
description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release.',
description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.',
api: this.serverService.analyzeFiles(),
successMessage: 'File analysis has been queued'
},

View file

@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators';
import { MemberService } from 'src/app/_services/member.service';
import { Member } from 'src/app/_models/auth/member';
@ -13,11 +13,15 @@ import { InviteUserComponent } from '../invite-user/invite-user.component';
import { EditUserComponent } from '../edit-user/edit-user.component';
import { ServerService } from 'src/app/_services/server.service';
import { Router } from '@angular/router';
import { TagBadgeComponent } from '../../shared/tag-badge/tag-badge.component';
import { NgFor, NgIf, AsyncPipe, TitleCasePipe, DatePipe } from '@angular/common';
@Component({
selector: 'app-manage-users',
templateUrl: './manage-users.component.html',
styleUrls: ['./manage-users.component.scss']
selector: 'app-manage-users',
templateUrl: './manage-users.component.html',
styleUrls: ['./manage-users.component.scss'],
standalone: true,
imports: [NgFor, NgIf, NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, DatePipe]
})
export class ManageUsersComponent implements OnInit, OnDestroy {

View file

@ -4,12 +4,16 @@ import { Member } from 'src/app/_models/auth/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { NgFor } from '@angular/common';
@Component({
selector: 'app-role-selector',
templateUrl: './role-selector.component.html',
styleUrls: ['./role-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-role-selector',
templateUrl: './role-selector.component.html',
styleUrls: ['./role-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgFor, ReactiveFormsModule, FormsModule]
})
export class RoleSelectorComponent implements OnInit {

View file

@ -26,14 +26,21 @@ import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { MessageHubService, Message, EVENTS } from 'src/app/_services/message-hub.service';
import { SeriesService } from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SeriesCardComponent } from '../../../cards/series-card/series-card.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { NgIf, DecimalPipe } from '@angular/common';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
@Component({
selector: 'app-all-series',
templateUrl: './all-series.component.html',
styleUrls: ['./all-series.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-all-series',
templateUrl: './all-series.component.html',
styleUrls: ['./all-series.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe]
})
export class AllSeriesComponent implements OnInit {

View file

@ -1,19 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AllSeriesRoutingModule } from './all-series-routing.module';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
import { AllSeriesComponent } from './_components/all-series/all-series.component';
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@NgModule({
declarations: [
AllSeriesComponent
],
imports: [
CommonModule,
AllSeriesRoutingModule,
SharedSideNavCardsModule
]
imports: [
CommonModule,
AllSeriesRoutingModule,
SeriesCardComponent,
BulkOperationsComponent,
CardDetailLayoutComponent,
SideNavCompanionBarComponent,
AllSeriesComponent,
]
})
export class AllSeriesModule { }

View file

@ -1,9 +1,13 @@
import { Component } from '@angular/core';
import { ChangelogComponent } from '../changelog/changelog.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
@Component({
selector: 'app-announcements',
templateUrl: './announcements.component.html',
styleUrls: ['./announcements.component.scss']
selector: 'app-announcements',
templateUrl: './announcements.component.html',
styleUrls: ['./announcements.component.scss'],
standalone: true,
imports: [SideNavCompanionBarComponent, ChangelogComponent]
})
export class AnnouncementsComponent {

View file

@ -1,11 +1,16 @@
import { Component, OnInit } from '@angular/core';
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
import { ServerService } from 'src/app/_services/server.service';
import { LoadingComponent } from '../../../shared/loading/loading.component';
import { ReadMoreComponent } from '../../../shared/read-more/read-more.component';
import { NgFor, NgIf, DatePipe } from '@angular/common';
@Component({
selector: 'app-changelog',
templateUrl: './changelog.component.html',
styleUrls: ['./changelog.component.scss']
selector: 'app-changelog',
templateUrl: './changelog.component.html',
styleUrls: ['./changelog.component.scss'],
standalone: true,
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe]
})
export class ChangelogComponent implements OnInit {

View file

@ -3,23 +3,23 @@ import { CommonModule } from '@angular/common';
import { AnnouncementsComponent } from './_components/announcements/announcements.component';
import { ChangelogComponent } from './_components/changelog/changelog.component';
import { AnnouncementsRoutingModule } from './announcements-routing.module';
import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { SidenavModule } from '../sidenav/sidenav.module';
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@NgModule({
declarations: [
AnnouncementsComponent,
ChangelogComponent
],
imports: [
CommonModule,
AnnouncementsRoutingModule,
SharedModule,
PipeModule,
SidenavModule
]
imports: [
CommonModule,
AnnouncementsRoutingModule,
ReadMoreComponent,
LoadingComponent,
SideNavCompanionBarComponent,
AnnouncementsComponent,
ChangelogComponent
]
})
export class AnnouncementsModule { }

View file

@ -2,7 +2,8 @@
<app-nav-header></app-nav-header>
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
<a id="content"></a>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async as sideNavVisibile"></app-side-nav>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
<div class="container-fluid" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}">
<div style="padding: 20px 0 0;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false}">

View file

@ -1,33 +1,37 @@
import { Component, HostListener, Inject, OnInit } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { map, take } from 'rxjs/operators';
import { NavigationStart, Router, RouterOutlet } from '@angular/router';
import {map, shareReplay, take} from 'rxjs/operators';
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 { filter } from 'rxjs/operators';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT } from '@angular/common';
import { DOCUMENT, NgClass, NgIf, AsyncPipe } from '@angular/common';
import { Observable } from 'rxjs';
import {ThemeService} from "./_services/theme.service";
import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.component';
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: true,
imports: [NgClass, NgIf, SideNavComponent, RouterOutlet, AsyncPipe, NavHeaderComponent]
})
export class AppComponent implements OnInit {
transitionState$!: Observable<boolean>;
constructor(private accountService: AccountService, public navService: NavService,
private messageHub: MessageHubService, private libraryService: LibraryService,
router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
@Inject(DOCUMENT) private document: Document) {
constructor(private accountService: AccountService, public navService: NavService,
private libraryService: LibraryService,
private router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {
// Setup default rating config
ratingConfig.max = 5;
ratingConfig.resettable = true;
// Close any open modals when a route change occurs
router.events
.pipe(filter(event => event instanceof NavigationStart))
@ -52,8 +56,8 @@ export class AppComponent implements OnInit {
}
ngOnInit(): void {
this.setCurrentUser();
this.setDocHeight();
this.setCurrentUser();
}
setCurrentUser() {
@ -61,8 +65,10 @@ export class AppComponent implements OnInit {
this.accountService.setCurrentUser(user);
if (user) {
this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user));
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
}
// Bootstrap anything that's needed
this.accountService.hasValidLicense().subscribe();
this.themeService.getThemes().subscribe();
this.libraryService.getLibraryNames().pipe(take(1), shareReplay()).subscribe();
}
}
}

View file

@ -1,50 +0,0 @@
import { BrowserModule, Title } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { ToastrModule } from 'ngx-toastr';
import { ErrorInterceptor } from './_interceptors/error.interceptor';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { SidenavModule } from './sidenav/sidenav.module';
import { NavModule } from './nav/nav.module';
// Disable Web Animations if the user's browser (such as iOS 12.5.5) does not support this.
const disableAnimations = !('animate' in document.documentElement);
if (disableAnimations) console.error("Web Animations have been disabled as your current browser does not support this.");
@NgModule({
declarations: [
AppComponent,
],
imports: [
HttpClientModule,
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule.withConfig({ disableAnimations }),
SidenavModule,
NavModule,
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',
preventDuplicates: true,
timeOut: 6000,
countDuplicates: true,
autoDismiss: true
}),
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
Title,
{ provide: SAVER, useFactory: getSaver },
],
bootstrap: [AppComponent]
})
export class AppModule { }

View file

@ -86,7 +86,8 @@
</ng-container>
<div #bookContainer class="book-container {{WritingStyleClass}}" [ngClass]="{'immersive' : immersiveMode}">
<div #readingHtml class="book-content {{ColumnLayout}} {{WritingStyleClass}}" [ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}"
<div #readingHtml class="book-content {{ColumnLayout}} {{WritingStyleClass}}"
[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>

View file

@ -184,8 +184,7 @@ $action-bar-height: 38px;
position: relative;
height: 100%;
//background-color: purple !important;
// background-color: purple !important;
&.column-layout-1 {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
@ -203,18 +202,17 @@ $action-bar-height: 38px;
}
.book-content {
position: relative;
padding: 20px 0;
margin: 0px 0px;
//background-color: red !important;
position: relative;
margin: 0 0;
padding: 20px 0px;
&.column-layout-1 {
height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2
&.column-layout-1 {
height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2
&.writing-style-vertical {
padding: 0 10px 0 0;
margin: 20px 0;
}
&.writing-style-vertical {
padding: 0 10px 0 0;
margin: 20px 0;
}
}
&.column-layout-2 {
@ -275,8 +273,6 @@ $action-bar-height: 38px;
word-break: break-word;
overflow-wrap: break-word;
}
}
.column-layout-2 {
@ -287,9 +283,6 @@ $action-bar-height: 38px;
word-break: break-word;
overflow-wrap: break-word;
}
}
// A bunch of resets so books render correctly

View file

@ -13,7 +13,7 @@ import {
RendererStyleFlags2,
ViewChild
} from '@angular/core';
import {DOCUMENT, Location} from '@angular/common';
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';
@ -37,12 +37,15 @@ import { LibraryService } from 'src/app/_services/library.service';
import { LibraryType } from 'src/app/_models/library';
import { BookTheme } from 'src/app/_models/preferences/book-theme';
import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode';
import { PageStyle } from '../reader-settings/reader-settings.component';
import { PageStyle, ReaderSettingsComponent } from '../reader-settings/reader-settings.component';
import { User } from 'src/app/_models/user';
import { ThemeService } from 'src/app/_services/theme.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { PAGING_DIRECTION } from 'src/app/manga-reader/_models/reader-enums';
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';
enum TabID {
@ -74,22 +77,24 @@ const pageLevelStyles = ['margin-left', 'margin-right', 'font-size'];
const elementLevelStyles = ['line-height', 'font-family'];
@Component({
selector: 'app-book-reader',
templateUrl: './book-reader.component.html',
styleUrls: ['./book-reader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('isLoading', [
state('false', style({opacity: 1})),
state('true', style({opacity: 0})),
transition('false <=> true', animate('200ms'))
]),
trigger('fade', [
state('true', style({opacity: 0})),
state('false', style({opacity: 0.5})),
transition('false <=> true', animate('4000ms'))
])
]
selector: 'app-book-reader',
templateUrl: './book-reader.component.html',
styleUrls: ['./book-reader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('isLoading', [
state('false', style({ opacity: 1 })),
state('true', style({ opacity: 0 })),
transition('false <=> true', animate('200ms'))
]),
trigger('fade', [
state('true', style({ opacity: 0 })),
state('false', style({ opacity: 0.5 })),
transition('false <=> true', animate('4000ms'))
])
],
standalone: true,
imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip]
})
export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ -379,7 +384,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
case BookPageLayoutMode.Default:
return 'unset';
case BookPageLayoutMode.Column1:
return (base / 2) + 'px';
return ((base / 2) - 4) + 'px';
case BookPageLayoutMode.Column2:
return (base / 4) + 'px';
default:
@ -786,15 +791,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a');
links.forEach((link: any) => {
link.addEventListener('click', (e: any) => {
if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; }
const page = parseInt(e.target.attributes['kavita-page'].value, 10);
let targetElem = e.target;
if (e.target.nodeName !== 'A' && e.target.parentNode.nodeName === 'A') {
// Certain combos like <a><sup>text</sup></a> can cause the target to be the sup tag and not the anchor
targetElem = e.target.parentNode;
}
if (!targetElem.attributes.hasOwnProperty('kavita-page')) { return; }
const page = parseInt(targetElem.attributes['kavita-page'].value, 10);
if (this.adhocPageHistory.peek()?.page !== this.pageNum) {
this.adhocPageHistory.push({page: this.pageNum, scrollPart: this.lastSeenScrollPartPath});
}
const partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined;
const partValue = targetElem.attributes.hasOwnProperty('kavita-part') ? targetElem.attributes['kavita-part'].value : undefined;
if (partValue && page === this.pageNum) {
this.scrollTo(e.target.attributes['kavita-part'].value);
this.scrollTo(targetElem.attributes['kavita-part'].value);
return;
}
@ -1123,8 +1133,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
getPageWidth() {
if (this.readingSectionElemRef == null) return 0;
const margin = (this.readingSectionElemRef.nativeElement.clientWidth * (parseInt(this.pageStyles['margin-left'], 10) / 100)) * 2;
const margin = (this.convertVwToPx(parseInt(this.pageStyles['margin-left'], 10)) * 2);
return this.readingSectionElemRef.nativeElement.clientWidth - margin + COLUMN_GAP;
}
@ -1141,6 +1150,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return windowWidth - margin;
}
convertVwToPx(vwValue: number) {
const viewportWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth || 0, window.innerWidth || 0);
return (vwValue * viewportWidth) / 100;
}
/**
* currentVirtualPage starts at 1
* @returns
@ -1166,7 +1180,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
return [currentVirtualPage, totalVirtualPages, pageSize];
}
private getScrollOffsetAndTotalScroll() {

View file

@ -1,27 +1,27 @@
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
<form [formGroup]="settingsForm">
<ngb-accordion [closeOthers]="false" #acc="ngbAccordion" [activeIds]="['general-panel', 'reader-panel', 'color-panel']">
<ngb-panel id="general-panel" title="General Settings">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
General Settings
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div class="control-container">
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
General Settings
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="control-container" >
<div class="controls">
<div class="mb-3">
<label for="library-type" class="form-label">Font Family</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
</select>
</div>
<div class="mb-3">
<label for="library-type" class="form-label">Font Family</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0 controls">
<label for="fontsize" class="form-label col-6">Font Size</label>
<span class="col-6 float-end" style="display: inline-flex;">
<label for="fontsize" class="form-label col-6">Font Size</label>
<span class="col-6 float-end" style="display: inline-flex;">
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
<input type="range" class="form-range ms-2 me-2" id="fontsize" min="50" max="300" step="10" formControlName="bookReaderFontSize" [ngbTooltip]="settingsForm.get('bookReaderFontSize')?.value + '%'">
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
@ -29,8 +29,8 @@
</div>
<div class="row g-0 controls">
<label for="linespacing" class="form-label col-6">Line Spacing</label>
<span class="col-6 float-end" style="display: inline-flex;">
<label for="linespacing" class="form-label col-6">Line Spacing</label>
<span class="col-6 float-end" style="display: inline-flex;">
1x
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
2.5x
@ -38,8 +38,8 @@
</div>
<div class="row g-0 controls">
<label for="margin" class="form-label col-6">Margin</label>
<span class="col-6 float-end" style="display: inline-flex;">
<label for="margin" class="form-label col-6">Margin</label>
<span class="col-6 float-end" style="display: inline-flex;">
<i class="fa-solid fa-outdent"></i>
<input type="range" class="form-range ms-2 me-2" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" [ngbTooltip]="settingsForm.get('bookReaderMargin')?.value + '%'">
<i class="fa-solid fa-indent"></i>
@ -47,117 +47,123 @@
</div>
<div class="row g-0 justify-content-between mt-2">
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
</div>
</div>
</ng-template>
</div>
</ng-template>
</div>
</ngb-panel>
</div>
<ngb-panel id="reader-panel" title="Reader Settings">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
Reader Settings
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
</div>
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
Reader Settings
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="readingdirection" class="form-label">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
</button>
<label id="readingdirection" class="form-label">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
</button>
</div>
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
<label for="writing-style" class="form-label">Writing Style <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
<ng-template #writingStyleTooltip>Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.</ng-template>
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical'}}">
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical' }}</span>
</button>
<label for="writing-style" class="form-label">Writing Style <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
<ng-template #writingStyleTooltip>Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.</ng-template>
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical'}}">
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical' }}</span>
</button>
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="tap-pagination" class="form-label">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
<ng-template #tapPaginationTooltip>Click the edges of the screen to paginate</ng-template>
<span class="visually-hidden" id="tapPagination-help">
<label for="tap-pagination" class="form-label">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
<ng-template #tapPaginationTooltip>Click the edges of the screen to paginate</ng-template>
<span class="visually-hidden" id="tapPagination-help">
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? 'On' : 'Off'}} </label>
</div>
<div class="form-check form-switch">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? 'On' : 'Off'}} </label>
</div>
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="immersive-mode" class="form-label">Immersive Mode&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
<ng-template #immersiveModeTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
<span class="visually-hidden" id="immersiveMode-help">
<label for="immersive-mode" class="form-label">Immersive Mode&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
<ng-template #immersiveModeTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
<span class="visually-hidden" id="immersiveMode-help">
<ng-container [ngTemplateOutlet]="immersiveModeTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? 'On' : 'Off'}} </label>
</div>
<div class="form-check form-switch">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? 'On' : 'Off'}} </label>
</div>
</div>
<!-- TODO: move this inline style into a class -->
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="fullscreen" class="form-label">Fullscreen&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top"
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
<ng-template #fullscreenTooltip>Put reader in fullscreen mode</ng-template>
<span class="visually-hidden" id="fullscreen-help">
<label id="fullscreen" class="form-label">Fullscreen&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top"
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
<ng-template #fullscreenTooltip>Put reader in fullscreen mode</ng-template>
<span class="visually-hidden" id="fullscreen-help">
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
</span>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
<span *ngIf="activeTheme?.isDarkTheme">&nbsp;{{isFullscreen ? 'Exit' : 'Enter'}}</span>
</button>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
<span *ngIf="activeTheme?.isDarkTheme">&nbsp;{{isFullscreen ? 'Exit' : 'Enter'}}</span>
</button>
</div>
<div class="controls">
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">Layout Mode&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
<ng-template #layoutTooltip>Scroll: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.</ng-template>
<span class="visually-hidden" id="layout-help">
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">Layout Mode&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
<ng-template #layoutTooltip>Scroll: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.</ng-template>
<span class="visually-hidden" id="layout-help">
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
</span>
<br>
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">Scroll</label>
<br>
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">Scroll</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">1 Column</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">1 Column</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col2">2 Column</label>
</div>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col2">2 Column</label>
</div>
</div>
</ng-template>
</ngb-panel>
</ng-template>
</div>
</div>
</div>
<ngb-panel id="color-panel" title="Color Theme">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
Color Theme
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div ngbAccordionItem id="color-panel" title="Color Theme" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
Color Theme
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls">
<ng-container *ngFor="let theme of themes">
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
{{theme.name}}
</button>
</ng-container>
<ng-container *ngFor="let theme of themes">
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
{{theme.name}}
</button>
</ng-container>
</div>
</ng-template>
</ngb-panel>
</ngb-accordion>
</ng-template>
</div>
</div>
</div>
</div>
</form>

View file

@ -1,4 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { DOCUMENT, NgFor, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -6,13 +6,11 @@ import {
EventEmitter,
inject,
Inject,
Input,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Subject, take, takeUntil } from 'rxjs';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { take } from 'rxjs';
import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode';
import { BookTheme } from 'src/app/_models/preferences/book-theme';
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
@ -27,6 +25,7 @@ import { BookDarkTheme } from '../../_models/book-dark-theme';
import { BookWhiteTheme } from '../../_models/book-white-theme';
import { BookPaperTheme } from '../../_models/book-paper-theme';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
/**
* Used for book reader. Do not use for other components
@ -81,10 +80,12 @@ export const bookColorThemes = [
const mobileBreakpointMarginOverride = 700;
@Component({
selector: 'app-reader-settings',
templateUrl: './reader-settings.component.html',
styleUrls: ['./reader-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-reader-settings',
templateUrl: './reader-settings.component.html',
styleUrls: ['./reader-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe]
})
export class ReaderSettingsComponent implements OnInit {
/**

View file

@ -1,12 +1,15 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
import { Subject } from 'rxjs';
import { BookChapterItem } from '../../_models/book-chapter-item';
import { NgIf, NgFor } from '@angular/common';
@Component({
selector: 'app-table-of-contents',
templateUrl: './table-of-contents.component.html',
styleUrls: ['./table-of-contents.component.scss'],
changeDetection: ChangeDetectionStrategy.Default
selector: 'app-table-of-contents',
templateUrl: './table-of-contents.component.html',
styleUrls: ['./table-of-contents.component.scss'],
changeDetection: ChangeDetectionStrategy.Default,
standalone: true,
imports: [NgIf, NgFor]
})
export class TableOfContentsComponent implements OnDestroy {

View file

@ -2,7 +2,8 @@ import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({
name: 'safeStyle'
name: 'safeStyle',
standalone: true
})
export class SafeStylePipe implements PipeTransform {

View file

@ -2,31 +2,29 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookReaderComponent } from './_components/book-reader/book-reader.component';
import { BookReaderRoutingModule } from './book-reader.router.module';
import { SharedModule } from '../shared/shared.module';
import { SafeStylePipe } from './_pipes/safe-style.pipe';
import { ReactiveFormsModule } from '@angular/forms';
import { PipeModule } from '../pipe/pipe.module';
import { NgbAccordionModule, NgbNavModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ReaderSettingsComponent } from './_components/reader-settings/reader-settings.component';
import { TableOfContentsComponent } from './_components/table-of-contents/table-of-contents.component';
import {DrawerComponent} from "../shared/drawer/drawer.component";
@NgModule({
declarations: [BookReaderComponent, SafeStylePipe, TableOfContentsComponent, ReaderSettingsComponent],
imports: [
CommonModule,
BookReaderRoutingModule,
ReactiveFormsModule,
SharedModule,
NgbProgressbarModule,
NgbTooltipModule,
PipeModule,
NgbTooltipModule,
NgbAccordionModule, // Drawer
NgbNavModule, // Drawer
], exports: [
BookReaderComponent,
SafeStylePipe
]
imports: [
CommonModule,
BookReaderRoutingModule,
ReactiveFormsModule,
NgbProgressbarModule,
NgbTooltipModule,
NgbTooltipModule,
NgbAccordionModule,
NgbNavModule,
DrawerComponent,
BookReaderComponent, SafeStylePipe, TableOfContentsComponent, ReaderSettingsComponent,
], exports: [
BookReaderComponent,
SafeStylePipe
]
})
export class BookReaderModule { }

View file

@ -18,12 +18,19 @@ import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { DecimalPipe } from '@angular/common';
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrls: ['./bookmarks.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrls: ['./bookmarks.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, CardItemComponent, DecimalPipe]
})
export class BookmarksComponent implements OnInit, OnDestroy {

View file

@ -2,20 +2,24 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookmarkRoutingModule } from './bookmark-routing.module';
import { BookmarksComponent } from './_components/bookmarks/bookmarks.component';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {CardItemComponent} from "../cards/card-item/card-item.component";
@NgModule({
declarations: [
BookmarksComponent
],
imports: [
CommonModule,
SharedSideNavCardsModule,
BookmarkRoutingModule
]
imports: [
CommonModule,
BookmarkRoutingModule,
BulkOperationsComponent,
CardDetailLayoutComponent,
SideNavCompanionBarComponent,
CardItemComponent,
BookmarksComponent
]
})
export class BookmarkModule { }

View file

@ -2,7 +2,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Collection</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<form style="width: 100%" [formGroup]="listForm">
@ -15,7 +15,7 @@
</div>
</div>
<ul class="list-group">
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
<li class="list-group-item clickable" tabindex="0" role="option" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" title="Promoted"></i>
</li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>

View file

@ -1,16 +1,20 @@
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { ReadingList } from 'src/app/_models/reading-list';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import {CommonModule} from "@angular/common";
import {FilterPipe} from "../../../pipe/filter.pipe";
@Component({
selector: 'app-bulk-add-to-collection',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FilterPipe, NgbModalModule],
templateUrl: './bulk-add-to-collection.component.html',
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
styleUrls: ['./bulk-add-to-collection.component.scss'],
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {

View file

@ -4,7 +4,7 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{TabID.General}}</a>
@ -13,7 +13,8 @@
<div class="row g-0 mb-3">
<div class="col-md-8 col-sm-12">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="title" type="text" [class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<input id="library-name" class="form-control" formControlName="title" type="text"
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
This field is required
@ -50,14 +51,14 @@
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let item of series; let i = index">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input"
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
@ -79,7 +80,9 @@
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{TabID.CoverImage}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)"
(selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked"
(resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>

Some files were not shown because too many files have changed in this diff Show more