The big one (#396)

* Refactored library card to have a custom implemenation using icons rather than images. In addition, swapped out font awesome with official version.

* Replaced pull-right with float-right due to updated bootstrap version.

* Added a new section to admin dashboard

* Added some menu system for reader, fit to width, height or original. Temp hack to make background black.

* Ability to set nav bar completely off from some pages. Removed test case that isn't used.

* Restore nav bar after reading

* Implemented ability to delete a series directly and scan from a series.

* Implemented some basic prefetching capabilities (just next page) and implemented proper reading direction support with a toggle.

* Added a no connection route for if backend goes down. Removed go to page functionality as it isn't really needed and overly complicated.

* Implemented ability to track progress and view it at a series level

* Read status enhancements, cleaned up card code a bit, styling changes on nav bar dropdown, read or continue functionality for series detail page.

* Fixed a few bugs around registering and refactored APIs to match backend.

* Lots of cleanup of the code and TODOs. Improved responsiveness on series detail page.

* Missed some changes

* Implemented ability to rate a series. review text will come in v0.2.

* Reverted some debug code for reader menu always being open. Added loader to reader as well.

* Setup for building prod for releasing Kavita server.

* After we create an admin for first time flow, refresh page so they can login.

* Small change to help user get to server settings to setup libraries

* Implemented ability to save what tab you are on or link directly to tab for admin dashboard.

* Implemented ability to reset another users password. Tweaked how error interceptor reacted to OK messages.

* Implemented general settings. Have ability to change cache directory, but disabled on BE.

* Remove SSL

* Implemented Volume 0's for series detail.

* Compressed image-placeholder and implemented refresh metadata. Refresh metadata will update cover images while scan library will just fix matching, etc.

* Refactored for backened architectural changes. Fixed some bugs around read progress off by one.

* Fixed some styling around grid layout for volume then chapters.

* On unauthorized, force logout then redirect to login page.

* Don't throw multiple toasters when somthing goes wrong due to backend going down.

* Implemented the ability to see and update server settings.

* Implemented user preferences and ability to update them. Fixed a bug in production code such that API requests are made on current domain.

* Small fixes around the app and server setting for port.

* Fixed some styling to look better on mobile devices and overflow text to eclipse.

* Cleanup and implemented card details for Volume/Chapters.

* Small tweak to card details

* Mark as Read/unread on Volumes now implemented.

* Cleaned up some code, integrated user settings into manga reader, took care of some todos.

* Forgot to sort chapters

* Fixed issue in card details with string concatentation

* Updated the Manga Reader to be better looking and simplier (code) on Desktop devices.

* Added more responsive breakpoints for overlay to look much better on more screen sizes

* Some changes for tablet. Clear out localStorage that is older than 1 page of what you're reading.

* Fix bug for continuing where you last left off.

* Fixed a bug where continue reading didn't take into account chapters.

* Cleaned up imports and added autocomplete skeleton.

* Small UX enhancements

* Moved manga-reader into it's own module to lessen default package size

* Removed route guards from reader module as it is handled by parent module.

* Responsive pass through on Series Detail page.

* Cleaned up containers and tooltips.

* Tooltip for icon only buttons

* Library editor modal cleanup

* Implemented nav bar for directory picker.

* Removed console.log

* Implemented a basic search for Kavita. Along the way did some CSS changes and error interceptor messages are better.

* Implemented a re-usable base64 image that can be styled. Not as easy as using inline styling, but easy to use.

* View encapsulation off so we can easily size these images.

* Implemented typeahead search for nav bar.

* Fix a bug when route parameters change, the series detail page wasn't updating with new information

* Implemented page splitting

* Cleaned up Card Details and split into 2 separate versions with unified Look and Feel.

* Implemented ability to mark a series as read/unread.

* Implemented Jump to First/Last page functionality as shortcuts to goToPage.

* Implemented pagination on Library Detail page

* Restore scroll position to the top on page route change

* Not sure if this changes anything, but IDE doesn't complain

* Added a cutsom favicon and small tweak on UI for library pagination controls.

* Bugfix to take into account currently reading chapter for read/continue button

* Implemented user reviews

* Forgot to hook up one click handler

* Only admins can edit a series

* Implemented edit series page. Many fields are not yet supported for modification.

* Hooked in Edit Series into backend. Fixed an ngIf on edit review button.

* Switched over existing series info modal to use the new edit one.

* Partially implemented download logs. Removed some files not needed and trialing css changes on actions menu for card items.

* Integrated Jest for Unit Testing and added one test case. Will expand and integrate into work flow.

* Cleaned up some mobile breakpoint styles. Looks much better on a phone.

* A bit more css around phones to make reader menu useable.

* Removed series-card-detail since it's been replaced with edit-series-modal.

* Implemented save logs

* Small cleanup

* More responsive breakpoint tweaks for nav bar.

* Fetching logs is fixed

* Bugfix: Search bar was visible for non-authenticated users

* Implemented the ability to correct (manually) a series title and for it to persist between scans. Small QoL changes throughout codebase.

* Added some broken test setup.

* Disable comments must start with space lint rule.

* Fixed issue where tablets wouldn't be able to render all images.

* Migrated code off localStorage and used one api to get information about chapter.

* Cleaned up the code now that we are loading images differently.

* Use circular array to cache image requests so that we can ensure next image is instantaneously available.

* Some fixes around ensuring we don't prefetch when going back a page and ensuring prefetch doesn't fetch more pages than there are left.

* Fixed #70: When marking as read from volume level, completion was off by 1 thus series level didn't show as completed.

* Fixed #72. Missing an else statement which allowed another navigate to override the correct code. Refactored hasReadingProgress to be set in setContinuePoint

* Cleaned up the User button in nav bar to be cleaner

* Implemented a custom confirm/alert service so that I have complete control over style.

* Missed LGTM exception

* First pass at removing base64 strings for images and using lazy loaded binary file images.

* Only load images that are within view (scroll port)

* Not connected message needs more top margin

* Move image handling to it's own service. Add transition for loading images and some cleanup

* Misc cleanup

* Refactored action items to a factory

* Refactored the card item actionables into factory and moved actionable rendering into one component

* Added an optional btn class input to allow styling menu as a button.

* Implemented the ability to reset your individual password.

* Wrong reset after resetting password

* Don't let user set log level. Not sure it's possible to implement in ASP.NET

* Implemented a carousel for streams component. Still needs some CSS and tweaking. Added some temp API endpoints for streams. Fixed a bug where after editing name on series underlying card didn't reflect.

* Everything but the css is done

* CSS done. Carousel components implemented

* More CSS stuff

* Small css change

* Some cleanup on code

* Add  aria-hidden="true" on icons

* Fixed css issue due to missing class

* Made scrolling carousel feel better on more screen sizes

* Fixed bug where confirm default buttons would all be cancel.

* Replaced placeholder image with a kavita placeholder. Added a theme folder for standardizing colors. Cleaned up some css here and there.

* Removed a dependency no longer needed. Implemented history based pagination for library detail page.

* Changed MangaFile numberOfPages to Page to match with new migration

* Fixed issue where if no chapters, we were just doing console.error instead of informing the user (should never happen)

* Add a todo for a future feature

* Implemented loading on series-detail volume section and fixed an issue where the whole series is just one volume and it's a special aka we can't parse vol/chapter from it, so it renders appropriately

* Fixed a rare issue where split pages would quickly flash both sides due to previously that page being fetched via onload and thus when render called, render got recalled.

* Fixed an off by 1 issue due to the fact that reading is 0-based and everything else is 1 based. (#94)

* Fixed an off by 1 issue due to the fact that reading is 0-based and everything else is 1 based. (#94) (#95)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials" (#96)

* Bugfixes! (#99)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials"

* Fixed a typo resulting in pages not rendering on edit series modal. Ensure chapters are sorted on edit series and card details modal.

* Fixed the date format showing days before months.

* Fixed a bug with scrollable modals for context info modals.

* Fixed a bug where adding a folder to a library added a / before the path, thus breaking on linux. (#101)

* Bugfixes (#103)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials"

* Fixed a typo resulting in pages not rendering on edit series modal. Ensure chapters are sorted on edit series and card details modal.

* Fixed the date format showing days before months.

* Fixed a bug with scrollable modals for context info modals.

* Last modified on chapters didn't really s how well and made no sense to show, removed it.

* Preparing for feature

* CSS adjustment for admin dashboard

* First flow, ensure we go directly to library tab

* When a user registers for first time, put them on login instead of relying on home page redirection.

* Fixed an issue with directory picker where the path separators didn't work for both linux and windows systems.

* Implement comic support (#104)

* Implement comic support

* Any disabled controls should show not-allowed pointer.

* Fixed a scroll bug on modal

* On connection lost, restore to previous page (#106)

* Implement comic support

* Any disabled controls should show not-allowed pointer.

* Fixed a scroll bug on modal

* If the server goes down between sessions and we go to not-connected page, try to restore previous route when connection regained.

* Fixed an issue where context menus weren't resetting when an admin logs out and a new non-admin logs in. (#108)

* Error Cards (#110)

* Fixed an issue where context menus weren't resetting when an admin logs out and a new non-admin logs in.

* Implemented a marker to inform the user that some archives can't be parsed.

* Don't show scrollbar if we don't have enough height to overflow

* Shows an error card when the underlying archive could not be read at all.

* Changed the card up

* Special grouping (#115)

* Implemented splitting specials into their own section for individual reading. Requires up to date backend for db changes.

* Cleaned up the code

* Replace underscores on specials if they exist. A simple name cleaning.

* Lots of Fixes (#126)

* Fixed After editing a user's library access, the Sharing details aren't updating without a refresh #116

* Fixed Series Summary & Review do not respect newline characters #114

* Default to non-specials tab and don't destroy DOM between tab changes

* Align UI api with backend

* Library icon should be "manga" for comic and Manga

* Fixed Mark Series as Read in series detail page doesn't update the volume/chapter cards unless page is refreshed. #118

* Fixed Defect: 2 Split pages in a row causes second page to not split #112

* Fixed an issue if last page is a splitpage, we wouldn't be able to see the other side of the split.

* When jumping to begining and end and both first page and last page are splitpages, make sure we set the paging direction so user can view both pages.

* Make sure we take into account splits when we try jump to first page then try to go "back" to the other split.

* Cleaned up split code a bit

* Fixed Go to Page is off by one #124

* Fixed Read button is showing continue when a show doesn't have any progress on it #121

* Implemented Read more component (Fixes #117)

* Fixed a bug in gotopage where if you went to maxPages or greater, we would always - 1 from page number.

* Forgot to commit this for Readmore component

* tslint cleanup

* Implemented Refactor Review to be bound to the star control rather than having a text review so the user can review without rating. #125

* Fixes #119 - 0 Volumes with 0 chapters were showing as specials, but should be in the special tab.

* Fixed an issue from reverting scanSeries code.

* Handle specials with a little more care

* Fixed #138. Search wasn't showing localizedName due to a rendering issue.

* Fixed an issue where L2R didn't handle multiple split pages in a row.

* Code smells

* Ensure we wipe context actions for library between login/logouts

* Fixed loading series after marking searies unread/read (#135)

* Removed isSpecial from volume (#137)

* Bugfix/gotopage (#139)

* Fixed #138

* Fixed #131 - getStem no longer removes the substring if lastPath is same as path.

* Implements Issue #129 - There is now a close book button on the menu

* Book Support (#141)

* Refactored Library Type dropdown to use an API so UI/Backend is in sync.

* Implemented the ability to load the book reader

* Book support but none of this works. Just here to keep track as I switch to other bugs

* Basic iframe implementation is now working

* Needed changes to load the content into native div rather than via iframe.

* We now have the ability to customize how we render the text.

* Removed console.log

* Implemented the ability to loadpages from remapped anchors from backend.

* Removed epubjs references and implemented table of contents api.

* Code now works for chapters with nested chapters

* Lots of changes, most of the reader is half baked, but foundation is there.

* Changed styles up a bit

* Implemented the ability to scroll to a part within a book. Added a custom font to test out.

* Show active page with a bolding when there are nested chapters

* Chapter group titles are now clickable

* Added the ability to set top offset in drawer

* Finally got style overrides to work and some other stuff

* User can now toggle menu with space

* Ensure styles don't leak. Drawer bottom was cutoff. On phone devices, default margins should be 0%.

* Use smooth scrolling when navigating between pages with scroll offset

* Added some code for checking when all images on page are loaded, added a fade in animation (doesnt work) and some media queries for top bar.

* Refactored all data structures in application into shared module

* CSS changes

* Fixed part selector query due to improper ids, now we use a more robust query type. Implemented a stack for adhoc clicks, so user can explore a bit but pagination is based on current page.

* Reverted sidenav changes. Fixed scrollTo to be more reliable with how the content comes into view.

* When you make an adhoc link jump, we now restore page and scroll position.

* Hooked in basic preferences for books and force margin settings for mobile devices.

* Book overrides now work all the time. Added a bunch of fonts for users to try out.

* Added all font faces

* A bit hacky, but darkMode now works for the reader.

* Remove styles on destroy

* First time users will have their menu open automatically

* Book format now shows on card details modal

* changed how margin updates to make more sense

* Fixed flashing by applying an opacity transition on page change.

* Code cleanup

* Reverted changes to unify series-detail page. Added some extra accessibility for book reader.

* Implement the ability to close drawer by clicking into the reader area

* Don't let the user page past where they should be able to

* Allow user to see the underlying values of customizations and when they save them, actually reset to their preferences

* Responsive top for sticky header

* Code smells

* Implemented the ability to update book settings from user settings

* code smells

* Code smells and max/mins on reader should match the user pref area

* Feature/feats and fixes (#144)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Feature/feats and fixes (#146)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Book Feedback and Fixes (#147)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Cleaned up carousel and fixed breakpoints so we always at least show 2 cards.

* Cleaned up menu for manga reader and changed how automatic scaling works, based on ratio of width to height rather than raw numbers.

* Fixed an issue where using left/right keys on book reader wouldn't behave like clicking left/right pagination buttons.

* Dark mode and click to paginate was conflicting. The hint overlay still doesn't work when dark mode is on.

* Book Feedback (#148)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Cleaned up carousel and fixed breakpoints so we always at least show 2 cards.

* Cleaned up menu for manga reader and changed how automatic scaling works, based on ratio of width to height rather than raw numbers.

* Fixed an issue where using left/right keys on book reader wouldn't behave like clicking left/right pagination buttons.

* Dark mode and click to paginate was conflicting. The hint overlay still doesn't work when dark mode is on.

* Fixed issue where errors from login flow would not throw a toastr

* Moved the progress bar and go to page into the side drawer to be less distracting when reading

* Removed console.logs

* Cleaned up styles on slider to be closer to size of cards

* Fixed an issue with swiper not allowing use of touch (#149)

* Fixed in progress by on last page incrementing to maxPages itself, thus ensuring it matches the sum of pages. (#151)

* Bugfix/in progress (#156)

* Fixed in progress by on last page incrementing to maxPages itself, thus ensuring it matches the sum of pages.

* Actually fix in progress by only incrementing page num on bookmark when we are on the last page

* Impleents tap to paginate user setting. (#157)

* Feature/manga reader (#160)

* Implemented pressing G to open go to page. Enhanced the dialog to give how many pages you can go to. On page splitting button press, if the current page needs splitting, we will re-render with the new option.

* Added gotopage shortcut key for book reader

* Setup for new feature

* Swiper now respects card sizes

* Fixes #51 and updates dependencies for security vulnerabilities

* Implemented back to top button

* Remove the - 1 hack from series-detail

* Remove hack from carad item

* Fix a regression where book reader would +1 pageNum for bookmarking on last page, but because books don't start at 0 for page nums, it isn't necessariy

* Implemented the ability to move between volumes automatically

* Additional security fix

* Code smells

* Cleaned up the implementation to properly prevent pagination when loading next chapter/volume

* v0.4 Last touches (#162)

* PurgeCSS integration

* Changed some icons to have titles

* Automatic scaling changes

* Removed 2 font families that didn't make the release cut. Fixed an off by 1 regression with setContinuePoint

* Backed out purge css after testing

* Some cleanup of the package

* Automatic scaling adjustments

* Bugfix/release shakeout (#164)

* Fixed body color not being reset due to capturing it too late

* Removed some dead code

* v0.4 merge to stable (#165)

* Fixed an off by 1 issue due to the fact that reading is 0-based and everything else is 1 based. (#94)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials" (#96)

* Bugfixes! (#99)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials"

* Fixed a typo resulting in pages not rendering on edit series modal. Ensure chapters are sorted on edit series and card details modal.

* Fixed the date format showing days before months.

* Fixed a bug with scrollable modals for context info modals.

* Fixed a bug where adding a folder to a library added a / before the path, thus breaking on linux. (#101)

* Bugfixes (#103)

* Fixed an issue where special case that handles no volumes was showing also when chapters also existed. Renamed "Chapter 0" as "Specials"

* Fixed a typo resulting in pages not rendering on edit series modal. Ensure chapters are sorted on edit series and card details modal.

* Fixed the date format showing days before months.

* Fixed a bug with scrollable modals for context info modals.

* Last modified on chapters didn't really s how well and made no sense to show, removed it.

* Preparing for feature

* CSS adjustment for admin dashboard

* First flow, ensure we go directly to library tab

* When a user registers for first time, put them on login instead of relying on home page redirection.

* Fixed an issue with directory picker where the path separators didn't work for both linux and windows systems.

* Implement comic support (#104)

* Implement comic support

* Any disabled controls should show not-allowed pointer.

* Fixed a scroll bug on modal

* On connection lost, restore to previous page (#106)

* Implement comic support

* Any disabled controls should show not-allowed pointer.

* Fixed a scroll bug on modal

* If the server goes down between sessions and we go to not-connected page, try to restore previous route when connection regained.

* Fixed an issue where context menus weren't resetting when an admin logs out and a new non-admin logs in. (#108)

* Error Cards (#110)

* Fixed an issue where context menus weren't resetting when an admin logs out and a new non-admin logs in.

* Implemented a marker to inform the user that some archives can't be parsed.

* Don't show scrollbar if we don't have enough height to overflow

* Shows an error card when the underlying archive could not be read at all.

* Changed the card up

* Special grouping (#115)

* Implemented splitting specials into their own section for individual reading. Requires up to date backend for db changes.

* Cleaned up the code

* Replace underscores on specials if they exist. A simple name cleaning.

* Lots of Fixes (#126)

* Fixed After editing a user's library access, the Sharing details aren't updating without a refresh #116

* Fixed Series Summary & Review do not respect newline characters #114

* Default to non-specials tab and don't destroy DOM between tab changes

* Align UI api with backend

* Library icon should be "manga" for comic and Manga

* Fixed Mark Series as Read in series detail page doesn't update the volume/chapter cards unless page is refreshed. #118

* Fixed Defect: 2 Split pages in a row causes second page to not split #112

* Fixed an issue if last page is a splitpage, we wouldn't be able to see the other side of the split.

* When jumping to begining and end and both first page and last page are splitpages, make sure we set the paging direction so user can view both pages.

* Make sure we take into account splits when we try jump to first page then try to go "back" to the other split.

* Cleaned up split code a bit

* Fixed Go to Page is off by one #124

* Fixed Read button is showing continue when a show doesn't have any progress on it #121

* Implemented Read more component (Fixes #117)

* Fixed a bug in gotopage where if you went to maxPages or greater, we would always - 1 from page number.

* Forgot to commit this for Readmore component

* tslint cleanup

* Implemented Refactor Review to be bound to the star control rather than having a text review so the user can review without rating. #125

* Fixes #119 - 0 Volumes with 0 chapters were showing as specials, but should be in the special tab.

* Fixed an issue from reverting scanSeries code.

* Handle specials with a little more care

* Fixed #138. Search wasn't showing localizedName due to a rendering issue.

* Fixed an issue where L2R didn't handle multiple split pages in a row.

* Code smells

* Ensure we wipe context actions for library between login/logouts

* Fixed loading series after marking searies unread/read (#135)

* Removed isSpecial from volume (#137)

* Bugfix/gotopage (#139)

* Fixed #138

* Fixed #131 - getStem no longer removes the substring if lastPath is same as path.

* Implements Issue #129 - There is now a close book button on the menu

* Book Support (#141)

* Refactored Library Type dropdown to use an API so UI/Backend is in sync.

* Implemented the ability to load the book reader

* Book support but none of this works. Just here to keep track as I switch to other bugs

* Basic iframe implementation is now working

* Needed changes to load the content into native div rather than via iframe.

* We now have the ability to customize how we render the text.

* Removed console.log

* Implemented the ability to loadpages from remapped anchors from backend.

* Removed epubjs references and implemented table of contents api.

* Code now works for chapters with nested chapters

* Lots of changes, most of the reader is half baked, but foundation is there.

* Changed styles up a bit

* Implemented the ability to scroll to a part within a book. Added a custom font to test out.

* Show active page with a bolding when there are nested chapters

* Chapter group titles are now clickable

* Added the ability to set top offset in drawer

* Finally got style overrides to work and some other stuff

* User can now toggle menu with space

* Ensure styles don't leak. Drawer bottom was cutoff. On phone devices, default margins should be 0%.

* Use smooth scrolling when navigating between pages with scroll offset

* Added some code for checking when all images on page are loaded, added a fade in animation (doesnt work) and some media queries for top bar.

* Refactored all data structures in application into shared module

* CSS changes

* Fixed part selector query due to improper ids, now we use a more robust query type. Implemented a stack for adhoc clicks, so user can explore a bit but pagination is based on current page.

* Reverted sidenav changes. Fixed scrollTo to be more reliable with how the content comes into view.

* When you make an adhoc link jump, we now restore page and scroll position.

* Hooked in basic preferences for books and force margin settings for mobile devices.

* Book overrides now work all the time. Added a bunch of fonts for users to try out.

* Added all font faces

* A bit hacky, but darkMode now works for the reader.

* Remove styles on destroy

* First time users will have their menu open automatically

* Book format now shows on card details modal

* changed how margin updates to make more sense

* Fixed flashing by applying an opacity transition on page change.

* Code cleanup

* Reverted changes to unify series-detail page. Added some extra accessibility for book reader.

* Implement the ability to close drawer by clicking into the reader area

* Don't let the user page past where they should be able to

* Allow user to see the underlying values of customizations and when they save them, actually reset to their preferences

* Responsive top for sticky header

* Code smells

* Implemented the ability to update book settings from user settings

* code smells

* Code smells and max/mins on reader should match the user pref area

* Feature/feats and fixes (#144)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Feature/feats and fixes (#146)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Book Feedback and Fixes (#147)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Cleaned up carousel and fixed breakpoints so we always at least show 2 cards.

* Cleaned up menu for manga reader and changed how automatic scaling works, based on ratio of width to height rather than raw numbers.

* Fixed an issue where using left/right keys on book reader wouldn't behave like clicking left/right pagination buttons.

* Dark mode and click to paginate was conflicting. The hint overlay still doesn't work when dark mode is on.

* Book Feedback (#148)

* In case a migration is poorly implemented, default on first load of bookreader.

* If there is no table of contents in epub file, inform the user

* Fixed #143 by ensuring we properly flatten the correct property when catching errors.

* Fixed #140. Search bar in nav is now more responsive than ever and properly scales down to even the smallest phone sizes (less than 300px)

* For Cards, moved the action menu into the bottom area, added Library link that said series belongs to.

* Added library to the series detail card

* Implemented the ability to automatically scale the manga reader based on screen size.

* Fix code smells

* Use margin-top instead of top for offsetting top

* Add a little extra spacing just in case

* Updated carousel to use a swpier

* Increased the budget and changed how vendor module is created

* Added some todos

* Implemented the ability to suppress library link on cards

* Fixed an issue with top offset for reading section

* Added the action bar to the bottom when user scrolls all the way down (Feedback)

* Added in a skip to content link for top nav

* After performing an action on library page, refresh the data on page.

* Implemented the ability to refresh metadata of a single series directly

* Implemented a progress bar for reading and a go to page by clicking the progress bar

* Only show the bottom action bar when there is a scrollbar

* Implemented the ability to tap the sides of book reader to paginate

* Cleaned up carousel and fixed breakpoints so we always at least show 2 cards.

* Cleaned up menu for manga reader and changed how automatic scaling works, based on ratio of width to height rather than raw numbers.

* Fixed an issue where using left/right keys on book reader wouldn't behave like clicking left/right pagination buttons.

* Dark mode and click to paginate was conflicting. The hint overlay still doesn't work when dark mode is on.

* Fixed issue where errors from login flow would not throw a toastr

* Moved the progress bar and go to page into the side drawer to be less distracting when reading

* Removed console.logs

* Cleaned up styles on slider to be closer to size of cards

* Fixed an issue with swiper not allowing use of touch (#149)

* Fixed in progress by on last page incrementing to maxPages itself, thus ensuring it matches the sum of pages. (#151)

* Bugfix/in progress (#156)

* Fixed in progress by on last page incrementing to maxPages itself, thus ensuring it matches the sum of pages.

* Actually fix in progress by only incrementing page num on bookmark when we are on the last page

* Impleents tap to paginate user setting. (#157)

* Feature/manga reader (#160)

* Implemented pressing G to open go to page. Enhanced the dialog to give how many pages you can go to. On page splitting button press, if the current page needs splitting, we will re-render with the new option.

* Added gotopage shortcut key for book reader

* Setup for new feature

* Swiper now respects card sizes

* Fixes #51 and updates dependencies for security vulnerabilities

* Implemented back to top button

* Remove the - 1 hack from series-detail

* Remove hack from carad item

* Fix a regression where book reader would +1 pageNum for bookmarking on last page, but because books don't start at 0 for page nums, it isn't necessariy

* Implemented the ability to move between volumes automatically

* Additional security fix

* Code smells

* Cleaned up the implementation to properly prevent pagination when loading next chapter/volume

* v0.4 Last touches (#162)

* PurgeCSS integration

* Changed some icons to have titles

* Automatic scaling changes

* Removed 2 font families that didn't make the release cut. Fixed an off by 1 regression with setContinuePoint

* Backed out purge css after testing

* Some cleanup of the package

* Automatic scaling adjustments

* Bugfix/release shakeout (#164)

* Fixed body color not being reset due to capturing it too late

* Removed some dead code

* Implemented dark mode (#166)

* Implemented dark mode

* Bump version to v0.4.1, moved dark styles to own stylesheet (some files need dark overrides) and ensured all pages are styled correctly.

* Switched the code over to use bootstrap theme with Kavita color

* Bugfix/manga issues (#169)

* Fixes #168

* Fixed a bug on the manga reader that caused the background color to inherit from body rather than be forced black.
Fixed an issue where a long filename on a phone could make it hard to close menu once open.

* Sentry Integration (#170)

* Basic version of sentry is implemented

* Enhanced continuous reading to show a warning when we couldn't find the next reading point. This will also short circuit after the first warning is shown

* Implemented Sentry. Currently src maps aren't uploading

* Bugfixes/misc (#174)

* Fixes #171

* Ensure btn-information is properly styled in dark mode

* no trace sampling for UI

* Fixed an issue where when we had no read progress, when choosing firs… (#176)

* Fixed an issue where when we had no read progress, when choosing first volume, we'd use first chapter, but sometimes chapters wouldn't be ordered.

* Code smell

* Collection Support (#179)

* Home button should go to library page, so we can use back and return to where we clicked from.

* Implemented Collection Support

* Fixed an issue for search in nav bar in darkmode

* Move loading to the top of the book reader

* Added DOMHelper to help with accessibility

* Implemented a re-usable layout component for all card layout screens. Handles pagination.

* Fixes #175

* Additional RBS check for tags where the tag fragment is invalid or there are no libraries that a user has access to

* Introduced an edit collection tag modal and actionables for collection tags.

* Bump version of Sentry SDK.

* Ability to remove series from a tag in a bulk manner.

* Continue Reading Regression (#186)

* Added a dark placeholder image for dark mode and hooked it up to Image service to load correct placeholder

* Fixed #181. Rewrote the continue logic to only check chapters and removed the concept of volumes (since every volume has a chapter). Opening a volume now does it's own check if there is progress on the volume, it will open to where the user left off. Otherwise, it will grab the first chapter and start at the beginning.

* Added dark error placeholder image (#187)

* Bugfix/misc (#188)

* Fixed an issue where carousel series cards scan library would kick off for wrong library id.

* Refactored the tab code to be dynamic based on the volume/chapter/specials of the data. The correct tab will be default selected and tabs that don't need to exist wont.

* Some css adjustments for typeaheads

* Move the loader out of the action bar so if settings menu is open when navigating pages, the math doesn't break

* Fixed a bug where highlight wasn't updating correctly when we type or after we add a tag via keyboard

* Fix an exception when tags are null (due to a bug in release)

* Accessibility bugs

* Collection Tweaks (#190)

* Fixed an issue where carousel series cards scan library would kick off for wrong library id.

* Refactored the tab code to be dynamic based on the volume/chapter/specials of the data. The correct tab will be default selected and tabs that don't need to exist wont.

* Some css adjustments for typeaheads

* Move the loader out of the action bar so if settings menu is open when navigating pages, the math doesn't break

* Fixed a bug where highlight wasn't updating correctly when we type or after we add a tag via keyboard

* Fix an exception when tags are null (due to a bug in release)

* Accessibility bugs

* Fixed #189 and cleaned up series pagination.

* Major cleanup of the typeahead code. One bug remaining

* Fixed highlight issue

* Fixed #183. When using continuous manga reading, moving to another chapter within the reader now updates the url. (#191)

* Book Parity: Reading direction for books (#192)

* Fixed pagination issue on library-detail

* Implemented left to right/right to left reading mode in book reader

* feat: remove Webtoon option from Library Types (#194)

#251

* Book Reading Progress Enhancement (#196)

* Implemented the ability to bookmark and restore reading progress (scroll) for books.

* Check to make sure we have something to search before we perform a querySelectorAll

* Don't reload a page when we've hit the boundaries of min/max pages and are trying to spam left/right key.

* Fixed a bug where if kavita-part marker was on the same page, the conditional was always true, meaning that when it was on a different one, we wouldn't load it up.

* Bugfix/tab refactor (#197)

* Fixed a logic bug which hid the specials tab too aggressively

* Unsubscribe from observables on destroy of account service

* Recently Added Page (#198)

* Recently Added Page
* Changed default pagination to 30

* Update to CSS for homepage section title links (#201)

* Update to CSS for homepage section title links

* Adding :active and :focus selectors

- :active for accessibility best practice and UX.
- :focus for mobile.

* Fixed #202 - Scope list item hover styles in darkmode to only typeahead (#204)

* Double Flashing Fix (#206)

* Fixed #202 - Scope list item hover styles in darkmode to only typeahead

* Fixed #199 - Flickering when paginating

* Fixed an issue with Continue Reading not working after manually updating a volume with multiple chapters as read/unread (#211)

* Directory Picker UX Enhancements (#214)

* Added a filter and some css to the directory picker to make it more useable

* Fixed a bug where last goBack didn't reload the disks and kept the directories from the last selected node.

* Allow user to change port (#215)

* Allow the admin to configure the log level from the UI. Add a warning informing them restart is required for port and log level. (#217)

* Cleaned up some console.logs and tweaked the logic of scroll position remembering. Now the side nav chapter list will show what part you are on (if applicable). (#220)

* Specials Sort (#223)

* Implemented a natural sort (same as BE) to sort the specials so the order isn't completely random

* Added ability to push source maps tagged to a release on push to main and develop (#219)

* Create Library Feedback (#224)

# Added
- Library type and number of folders shared is now visible on Manage Libraries page

# Changed
- Directory Picker will now let you share the current folder from any time in the picker flow
- Headings are now consistent between User Preferences and Admin screen

* Fixing folder structure for sentry github action (#225)

* Updating workflow environment (#226)

Sentry workflow was giving an error: "Error: Container action is only supported on Linux"

* Fixing build dist path for sentry (#227)

* Updating workflow environment

Sentry workflow was giving an error: "Error: Container action is only supported on Linux"

* update build dist path for sentry

* fix: unable to select lib type when creating a new lib (#231)

* fix: unable to select lib type when creating a new lib

fixed #230

* fix: able to change lib type after it's creation

* Download Support (#229)

* Implemented the ability to download series/chapter/volume from server. Uses RBS to determine if a user can or cannot download.

* Safety Checks (#233)

* Fixes a safety check from Sentry ANGULAR-1Z

* Fixed a build issue from downloading branch

* Fix/234 235 login redirection and dark theme not working (#236)

* fix: login redirection not happening

#234

* fix: dark theme not working after logout

#235

* Remove SP marker from specials and also remove extension from specials. (#238)

* Remove SP marker from specials and also remove extension from specials.

* Sort first so we can take advantage of the SP number

* Error Handling Rework (#237)

* Updated ngx-toastr version (includes new styles), updated style.scss to be cleaner. Began adding Title service for accessibility.

* Reworked error interceptor and toastr service to reduce duplicates and properly show errors.

Co-authored-by: Milazzo, Joseph (jm520e) <jm520e@us.att.com>

* Fixed a prod only issue due to multi: true for provider (#243)

* Feat/usage stats collection (#245)

* feat: add client anonymous data collection

* fix: sonar issues

* Implemented a server setting to opt-out of usage collection

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>

* Book Progress Enhancements (#250)

* Implemented the ability to bookmark any part of the book, not just the chapter parts

* Added total pages label to the book reader

* Manga Reader Redesign + Webtoon Reader (#247)

# New
- Bottom drawer has a scroller to jump to pages, jump to first/last page, and jump between volume/chapters. 
- Quick actions easily available to change reading direction, change reader mode, color tones, and access extended settings
- Extended settings area for settings unlikely changed
- Ability to auto close menu (setting)
- Ability to apply extra darkness or a sepia tone to reduce blue light
- New reader modes: Left/Right, Up/Down, Webtoon (scroll up and down)
- Information about the volume/chapter you are reading is now showed in the top drawer

# Changed
- When applying reader modes or reading directions, the clickable areas will now show an overlay to help you understand where to click.
- Image scaling and Image splitting now show some contextual icons to help the user understand what they do
- Close book button is now in the top drawer menu

* Bugfix/toastr css updates (#249)

* CSS Updates

- Removed BS4 toastr styles
- Reinstituted default non-BS4 toastr styles
- Centered login (accounting for header)
- Adjusted the carousel section heading font-size.
- Added a small padding (5px) on top of the padding for the nav, so the text isn't so close to the nav.

* Login refresh & toaster styles

- Added new font for login
- Updated login styles
- Hide nav bar on logout
- show nav bar on login
- Added images for new login
- dark styles for login
- dark styles for toastr

* minified images

* sonar bug fix

* updating style url for minified asset

* Fixes and code smells

- fix for login bg image showing up elsewhere
- fix for code smells
- added font family to nav bar

* Fixed missing label/input linking

* resized, compressed, and minified bg image

- change opacity to dark mode login

* Changed Spartan font files to variable weight

* Change requests

- Added font license
- Renamed image used for login bg
- Fixed path in styles where above file was used
- Removed now unused bs4 toastr style import

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>

* Fix a bad version number

* hotfix for docker build issue (#251)

* updating angular.json

changing output folder

* change path

* Fixed build issues (#252)

* Bugs! (#254)

* Fix style issue where bootstrap components weren't taking kavita overrides

* Fixed a bug where after updating certain things within a card on library page, the feeds wouldn't update

* Fixed a bug where chapter sort would not behave the same way as on chrome

* Release canidate bugs (#255)


  *  Auto Close menu wasn't updating within reader
  *  (Book Reader) Enhanced scroll part code to limit elements we count for bookmarking, only calculating intersection once fully visible and saving when scroll ends
 *   Removed Image Smoothing option (chrome only) from this release. No noticeable difference having it.
 * Fixed a page reload when clicking on In Progress section title on home page

* Bugfix/webtoons (#256)

* Fixed issue where first load would not start capturing scroll events due to not knowing the scroll to an element finished.

* Changed how to figure out when to end scrolling event by calculating if the target element is visible in the viewport.

* Seems to be working pretty well. Cleaned up some of the messages for debugging.

* Simplified the intersection logic drastically

* Fixed a color issue on slider for non-dark mode

* Disable first/last page buttons if we are already on those respective pages

* Added documentation to circular array class

* Some debug code but scrolling no longer results in jank due to scrollToPage getting executed too often

* Backing out ability to use webtoon reader

* Css fix for book reader progress and light mode toastr (#257)

* Changing dark mode to default (#262)

- Changed user-preferences site dark mode to default true

* added logo and css for logo (#260)

* added logo and css for logo

- max-height is to prevent the image from increasing the height of the navbar.
- middle middle vertical align didn't look to match up as expected, so a top middle was implemented based on chrome and firefox renderings.

* Adding requested accessibility changes

* Added Kavita-webui repo to UI/Web

* Special parsing issues (#361)

* Update README.md

Added demo link to Readme and tweaked Sentry icon

* Adds some regex cases from manga downloaded through FMD2. For parsing specials, if the marker is found, try to overwrite the series with the folder the manga is in, rather than what we parse from filename.

* Version bump

* Changed company to point to our domain

* Fixed copyright to point to our domain

* Adding test github workflow and test build file (#362)

* Fixing copy fail in monorepo-test workflow

* fixing shell script to be executable

* fixing permission issue

* Folder Parsing (#366)

* New: Ability to parse volume and chapter from directory tree, rather than exclusively from filename. (#313)
* Fixed: Fixed an edge case where GetFoldersTillRoot if given a non-existent root in the file path, would result in an infinite loop.

* Book Reader Bugs (#367)

* Fixed:  Fixed an issue where when tap to paginate is on, clicking off the settings menu doesn't close it.
* Fixed: Fixed the tint color on book reader being different from manga reader.
* Fixed: Reworked the clickable overlay for tap to paginate so links are still clickable when tap to paginate is on.

* Build on monorepo

* Book Reader Intersection Handler not firing  (#369)

* Fixed: Fixed an issue where intersection observer wouldn't be triggered when book page had no images (Book reader bookmark not firing while scrolling #360)

* Raw Image Support (#375)

* New: Ability to add Raw Image folders to Kavita via new library Types Images (Comic) and Images (Manga). Images must belong to a folder, they cannot exist in the root directory. It is important to at least put a second folder (minimum) with a Volume of Chapter, else you will end up with each image as a special which is not easily readable.
* Changed: When caching images for raw images, do it much faster and return earlier if the files have already been cached.


Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>

* Fixed a bug in the circular array which would not properly roll index over for applyFor (#377)

* Fixed: Manga reader's prefetching buffer had issues with rolling over index, which would require a manual image load every 7 pages. (#372)

* Adding new ui dist folder to gitignore

* Added stats folder persistence (#382)

* Added demo link to Readme and tweaked Sentry icon

* Added a symbolic link to persist the stats folder between docker container updates.

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>

* Lots of UI fixes and changes (#383)

* After we flatten, remove any non image files as we shouldn't iterate over them

* Fixed a case for next/prev chapter where if we have a volume then chapters attached afterwards, there would be improper movement due to how sorting works.

* Fixed an issue where no-connection would not resume where the loss of connection occured

* Fixed an issue where after creating a new user, their Last Active Date would show as a weird date, instead of "Never"

* Sort files in card detail to mimic reading order

* Implemented a single source of executing actions (card or main page) and added actionables on library detail page.

* Refactored series actions into action service

* Implemented common handling for entity actions in a dedicated service

* Fixed build script for new monorepo layout.

* Cleaned up nav header subscriptions

* Updated the favicon/icons to work on different devices as home screen shortcuts


* Fixed: Fixed issue where if you had a volume with 1 volume based file and a chapter file, the next/prev chapters wouldn't work (Fixes #380)
* Fixed: When connection is lost to backend, saving current page url and resuming when connection reestablished was not working (Fixes #379)
* Fixed: When creating a new user, a strange date format was shown in Last Active due to not having been active. Now "Never" shows (Fixes #376)
* Fixed: When showing files for a volume/chapter, the files are now sorted in the order you will read them in (Fixes #378)
* Added: Library detail now has actionable menu next to header, so you can kick off a scan or metadata refresh (Closes #363)
* Changed: When performing actions like marking as read/unread on series detail page, the actionable button will disable until the request finishes. (Closes #381)
* Changed: Favicon and Icons have been updated so when saving webpage to home screen, it should show a proper icon (Closes #356)

* Lots of Bugfixes and Tweaks (#387)

* Fixed: Fixed a bug in how we get images. When dealing with raw images, we need special logic (Monorepo)
* Added: (Manga Reader) When we are within 10 pages of the beginning of a manga, prefetch the prev chapter
* Fixed: (Manga Reader) The slider would sometime skip pages and would have leftover track on last page. 
* Fixed: (Raw Images) When calculating cover image for Raw Image entities, only select image files
* Fixed: Fixed a logic bug where raw image based entities wouldn't send back the correct page (Monorepo)
* Changed: When deleting a library, it can take a long time. Disable delete buttons until the deletion finishes
* Added: (Parser) Added a regex case for "Series - Ch. 20 - Part"
* Changed: Try to show the files in volume/chapter detail modal in the reading order. 
* Fixed: Next/Previous chapter was not working in all cases from previous Monorepo commit.

* Bugfix/locked name reset (#389)

* Fixed: Fixed an issue where if you manually rename a series, then remove/modify an entity related to the series, the series would be deleted and re-created with the original, parsed name.

* Scan Series (#390)

* Refactored Library delete to use a transaction.

* Ensure we parse "Series Name - Chapter XXX" before "Series Name - Vol XXX"

* Ensure if GetFoldersTillRoot is called with a fullPath containing a file, that we ignore the file for returned folders.

* Changed: From the series actionable menu, instead of scan library, which would kick off a filesystem scan on the library the series belonged to, instead we have "scan series" which will scan the folders represented by that series. If that series has files in the root of the library, the library root is scanned, but only said series files will be processed. This can make a refresh occur in under 500 ms (Fixes #371)
* Fixed: Fixed a bad parsing case for "Series Name - Vol.01 Chapter 029 8 Years Ago" where somehow the chapter would parse as "029 8", thus making the file a special rather than chapter 29.

* Fixes a bug where the root path and the full path share a common word, like root: "/Test library" and full path "/Test library/Test" which caused "/Test" to be taken out of root and thus GetFoldersTillRoot would never finish

* About Section (#394)


* Added: Added an about section with version, links to discord, github, donations, etc.
* Fixed: Fixed some parsing issues that caused "Series Name - Volume X Chapter Y" to parse as "Series Name - Volume X" from a previous change in develop.

* Cleaning up monorepo build files

* Fixing permission issues

Co-authored-by: Leonardo Dias <leo.rock14@gmail.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Leonardo Dias <contato.leonardod@yahoo.com>
Co-authored-by: Milazzo, Joseph (jm520e) <jm520e@us.att.com>
Co-authored-by: Chris Plaatjes <kizaing@gmail.com>
This commit is contained in:
Joseph Milazzo 2021-07-17 14:03:11 -05:00 committed by GitHub
parent 2da77da51b
commit 2a34fe4cc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
374 changed files with 53069 additions and 617 deletions

View file

@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@Injectable({
providedIn: 'root'
})
export class AdminGuard implements CanActivate {
constructor(private accountService: AccountService, private toastr: ToastrService) {}
canActivate(): Observable<boolean> {
// this automaticallys subs due to being router guard
return this.accountService.currentUser$.pipe(
map((user: User) => {
if (this.accountService.hasAdminRole(user)) {
return true;
}
this.toastr.error('You are not authorized to view this page.');
return false;
})
);
}
}

View file

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
canActivate(): Observable<boolean> {
return this.accountService.currentUser$.pipe(
map((user: User) => {
if (user) {
return true;
}
this.toastr.error('You are not authorized to view this page.');
this.router.navigateByUrl('/home');
return false;
})
);
}
}

View file

@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { MemberService } from '../_services/member.service';
@Injectable({
providedIn: 'root'
})
export class LibraryAccessGuard implements CanActivate {
constructor(private memberService: MemberService) {}
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const libraryId = parseInt(state.url.split('library/')[1], 10);
return this.memberService.hasLibraryAccess(libraryId);
}
}

View file

@ -0,0 +1,121 @@
import { Injectable, OnDestroy } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { catchError, take } from 'rxjs/operators';
import { AccountService } from '../_services/account.service';
import { environment } from 'src/environments/environment';
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
catchError(error => {
if (error === undefined || error === null) {
return throwError(error);
}
if (!environment.production) {
console.error('error:', error);
}
switch (error.status) {
case 400:
this.handleValidationError(error);
break;
case 401:
this.handleAuthError(error);
break;
case 404:
this.handleNotFound(error);
break;
case 500:
this.handleServerException(error);
break;
default:
// Don't throw multiple Something undexpected went wrong
if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') {
this.toastr.error('Something unexpected went wrong.');
}
// If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there
if (this.router.url !== '/no-connection') {
localStorage.setItem('kavita--no-connection-url', this.router.url);
this.router.navigateByUrl('/no-connection');
}
break;
}
return throwError(error);
})
);
}
private handleValidationError(error: any) {
// 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')) {
error.error.forEach((issue: {status: string, details: string, message: string}) => {
modalStateErrors.push(issue.details);
});
} else {
error.error.forEach((issue: {code: string, description: string}) => {
modalStateErrors.push(issue.description);
});
}
throw modalStateErrors.flat();
} else if (error.error.errors) {
// Validation error
const modalStateErrors = [];
for (const key in error.error.errors) {
if (error.error.errors[key]) {
modalStateErrors.push(error.error.errors[key]);
}
}
throw modalStateErrors.flat();
} else {
console.error('error:', error);
if (error.statusText === 'Bad Request') {
this.toastr.error(error.error, error.status);
} else {
this.toastr.error(error.statusText === 'OK' ? error.error : error.statusText, error.status);
}
}
}
private handleNotFound(error: any) {
this.toastr.error('That url does not exist.');
}
private handleServerException(error: any) {
console.error('500 error:', error);
const err = error.error;
if (err.hasOwnProperty('message') && err.message.trim() !== '') {
this.toastr.error(err.message);
} else {
this.toastr.error('There was an unknown critical error.');
}
}
private handleAuthError(error: any) {
// 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.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.toastr.error(error.statusText === 'OK' ? 'Unauthorized' : error.statusText, error.status);
}
this.accountService.logout();
});
}
}

View file

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

View file

@ -0,0 +1,53 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<form [formGroup]="collectionTagForm">
<div class="form-group">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
<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"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
</ul>
</div>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View file

@ -0,0 +1,121 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin } from 'rxjs';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-edit-collection-tags',
templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss']
})
export class EditCollectionTagsComponent implements OnInit {
@Input() tag!: CollectionTag;
series: Array<Series> = [];
selections!: SelectionModel<Series>;
isLoading: boolean = true;
pagination!: Pagination;
selectAll: boolean = true;
libraryNames!: any;
collectionTagForm!: FormGroup;
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService) { }
ngOnInit(): void {
if (this.pagination == undefined) {
this.pagination = {totalPages: 1, totalItems: 200, itemsPerPage: 200, currentPage: 0};
}
this.collectionTagForm = new FormGroup({
summary: new FormControl(this.tag.summary, []),
});
this.loadSeries();
}
onPageChange(pageNum: number) {
this.pagination.currentPage = pageNum;
this.loadSeries();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.series.forEach(s => this.selections.toggle(s, this.selectAll));
}
loadSeries() {
forkJoin([
this.seriesService.getSeriesForTag(this.tag.id, this.pagination.currentPage, this.pagination.itemsPerPage),
this.libraryService.getLibraryNames()
]).subscribe(results => {
const series = results[0];
this.pagination = series.pagination;
this.series = series.result;
this.selections = new SelectionModel<Series>(true, this.series);
this.isLoading = false;
this.libraryNames = results[1];
});
}
handleSelection(item: Series) {
this.selections.toggle(item);
const numberOfSelected = this.selections.selected().length;
if (numberOfSelected == 0) {
this.selectAll = false;
} else if (numberOfSelected == this.series.length) {
this.selectAll = true;
}
}
togglePromotion() {
const originalPromotion = this.tag.promoted;
this.tag.promoted = !this.tag.promoted;
this.collectionService.updateTag(this.tag).subscribe(res => {
this.toastr.success('Tag updated successfully');
}, err => {
this.tag.promoted = originalPromotion;
});
}
libraryName(libraryId: number) {
return this.libraryNames[libraryId];
}
close() {
this.modal.close(false);
}
async save() {
const unselectedIds = this.selections.unselected().map(s => s.id);
const tag: CollectionTag = {...this.tag};
tag.summary = this.collectionTagForm.get('summary')?.value;
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
return;
}
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)).subscribe(() => {
this.toastr.success('Tag updated');
this.modal.close(true);
});
}
get someSelected() {
const selected = this.selections.selected();
return (selected.length != this.series.length && selected.length != 0);
}
}

View file

@ -0,0 +1,169 @@
<div *ngIf="series !== undefined">
<div class="modal-header">
<h4 class="modal-title">
{{this.series.name}} Details</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="name">Name</label>
<input id="name" class="form-control" formControlName="name" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="sort-name">Sort Name</label>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="localized-name">Localized Name</label>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="author">Author</label>
<input id="author" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="author" type="text">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="artist">Artist</label>
<input id="artist" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="artist" type="text">
</div>
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="genres">Genres</label>
<input id="genres" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="genres" type="text">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="collections">Collections</label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="settings">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1]}}</a>
<ng-template ngbNavContent>
<p>Not Yet implemented</p>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent>
<p>Not Yet implemented</p>
<img src="{{imageService.getSeriesCoverImage(series.id)}}">
</ng-template>
</li>
<li [ngbNavItem]="tabs[3]">
<a ngbNavLink>{{tabs[3]}}</a>
<ng-template ngbNavContent>
<h4>Information</h4>
<div class="row no-gutters mb-2">
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | titlecase}}</div>
</div>
<h4>Volumes</h4>
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
<span class="invisible">Loading...</span>
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="media my-4" *ngFor="let volume of seriesVolumes">
<img class="mr-3" style="width: 74px;" src="{{imageService.getVolumeCoverImage(volume.id)}}" >
<div class="media-body">
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
<div>
<div class="row no-gutters">
<div class="col">
Created: {{volume.created | date: 'MM/dd/yyyy'}}
</div>
<div class="col">
Last Modified: {{volume.lastModified | date: 'MM/dd/yyyy'}}
</div>
</div>
<div class="row no-gutters">
<div class="col">
<!-- Is Special: {{volume.isSpecial}} -->
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
View Files
</button>
</div>
<div class="col">
Pages: {{volume.pages}}
</div>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
<ul class="list-group mt-2">
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="col">
Chapter: {{file.chapter}}
</div>
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<div class="modal-footer">
<!-- TODO: Replace secondary buttons in modals with btn-light -->
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>

View file

@ -0,0 +1,3 @@
.scrollable-modal {
height: 600px;
}

View file

@ -0,0 +1,148 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/series-metadata';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-edit-series-modal',
templateUrl: './edit-series-modal.component.html',
styleUrls: ['./edit-series-modal.component.scss']
})
export class EditSeriesModalComponent implements OnInit, OnDestroy {
@Input() series!: Series;
seriesVolumes: any[] = [];
isLoadingVolumes = false;
isCollapsed = true;
volumeCollapsed: any = {};
tabs = ['General', 'Fix Match', 'Cover Image', 'Info'];
active = this.tabs[0];
editSeriesForm!: FormGroup;
libraryName: string | undefined = undefined;
private readonly onDestroy = new Subject<void>();
settings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
tags: CollectionTag[] = [];
metadata!: SeriesMetadata;
constructor(public modal: NgbActiveModal,
private seriesService: SeriesService,
public utilityService: UtilityService,
private fb: FormBuilder,
public imageService: ImageService,
private libraryService: LibraryService,
private collectionService: CollectionTagService) { }
ngOnInit(): void {
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
this.libraryName = names[this.series.libraryId];
});
this.setupTypeaheadSettings();
this.editSeriesForm = this.fb.group({
id: new FormControl(this.series.id, []),
summary: new FormControl(this.series.summary, []),
name: new FormControl(this.series.name, []),
localizedName: new FormControl(this.series.localizedName, []),
sortName: new FormControl(this.series.sortName, []),
rating: new FormControl(this.series.userRating, []),
genres: new FormControl('', []),
author: new FormControl('', []),
artist: new FormControl('', []),
coverImageIndex: new FormControl(0, [])
});
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
if (metadata) {
this.metadata = metadata;
this.settings.savedData = metadata.tags;
}
});
this.isLoadingVolumes = true;
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
this.seriesVolumes = volumes;
this.isLoadingVolumes = false;
volumes.forEach(v => {
this.volumeCollapsed[v.name] = true;
});
this.seriesVolumes.forEach(vol => {
vol.volumeFiles = vol.chapters?.sort(this.utilityService.sortChapters).map((c: Chapter) => c.files.map((f: any) => {
f.chapter = c.number;
return f;
})).flat();
});
});
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
setupTypeaheadSettings() {
this.settings.minCharacters = 0;
this.settings.multiple = true;
this.settings.id = 'collections';
this.settings.unique = true;
this.settings.addIfNonExisting = true;
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter);
this.settings.addTransformFn = ((title: string) => {
return {id: 0, title: title, promoted: false, coverImage: '', summary: '' };
});
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
const f = filter.toLowerCase();
return options.filter(m => m.title.toLowerCase() === f);
}
}
close() {
this.modal.close({success: false, series: undefined});
}
fetchCollectionTags(filter: string = '') {
return this.collectionService.search(filter);
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
save() {
// TODO: In future (once locking or metadata implemented), do a converstion to updateSeriesDto
forkJoin([
this.seriesService.updateSeries(this.editSeriesForm.value),
this.seriesService.updateMetadata(this.metadata, this.tags)
]).subscribe(results => {
this.modal.close({success: true, series: this.editSeriesForm.value});
});
}
updateCollections(tags: CollectionTag[]) {
this.tags = tags;
}
}

View file

@ -0,0 +1,32 @@
<div *ngIf="series !== undefined">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">
{{series.name}} Review</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="form-group">
<label for="rating">Rating</label>
<div>
<ngb-rating style="margin-top: 2px; font-size: 1.5rem;" formControlName="rating"></ngb-rating>
<button class="btn btn-information ml-2" (click)="clearRating()"><i aria-hidden="true" class="fa fa-ban"></i><span class="phone-hidden">&nbsp;Clear</span></button>
</div>
</div>
<div class="form-group">
<label for="review">Review</label>
<textarea id="review" class="form-control" formControlName="review" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>

View file

@ -0,0 +1,41 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Series } from 'src/app/_models/series';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-review-series-modal',
templateUrl: './review-series-modal.component.html',
styleUrls: ['./review-series-modal.component.scss']
})
export class ReviewSeriesModalComponent implements OnInit {
@Input() series!: Series;
reviewGroup!: FormGroup;
constructor(public modal: NgbActiveModal, private seriesService: SeriesService) {}
ngOnInit(): void {
this.reviewGroup = new FormGroup({
review: new FormControl(this.series.userReview, []),
rating: new FormControl(this.series.userRating, [])
});
}
close() {
this.modal.close({success: false, review: null});
}
clearRating() {
this.reviewGroup.get('rating')?.setValue(0);
}
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});
});
}
}

View file

@ -0,0 +1,5 @@
export interface Bookmark {
pageNum: number;
chapterId: number;
bookScrollId?: string;
}

View file

@ -0,0 +1,14 @@
import { MangaFile } from './manga-file';
export interface Chapter {
id: number;
range: string;
number: string;
files: Array<MangaFile>;
coverImage: string;
pages: number;
volumeId: number;
pagesRead: number; // Attached for the given user when requesting from API
isSpecial: boolean;
title: string;
}

View file

@ -0,0 +1,12 @@
import { DetailsVersion } from "./details-version";
export interface ClientInfo {
os: DetailsVersion,
browser: DetailsVersion,
platformType: string,
kavitaUiVersion: string,
screenResolution: string;
collectedAt?: Date;
}

View file

@ -0,0 +1,7 @@
export interface CollectionTag {
id: number;
title: string;
promoted: boolean;
coverImage: string;
summary: string;
}

View file

@ -0,0 +1,4 @@
export interface DetailsVersion {
name: string;
version: string;
}

View file

@ -0,0 +1,14 @@
//TODO: Refactor this name to something better
export interface InProgressChapter {
id: number;
range: string;
number: string;
pages: number;
volumeId: number;
pagesRead: number;
seriesId: number;
seriesName: string;
coverImage: string;
libraryId: number;
libraryName: string;
}

View file

@ -0,0 +1,15 @@
export enum LibraryType {
Manga = 0,
Comic = 1,
Book = 2,
MangaImages = 3,
ComicImages = 4
}
export interface Library {
id: number;
name: string;
coverImage: string;
type: LibraryType;
folders: string[];
}

View file

@ -0,0 +1,7 @@
import { MangaFormat } from './manga-format';
export interface MangaFile {
filePath: string;
pages: number;
format: MangaFormat;
}

View file

@ -0,0 +1,6 @@
export enum MangaFormat {
IMAGE = 0,
ARCHIVE = 1,
UNKNOWN = 2,
BOOK = 3
}

View file

@ -0,0 +1,10 @@
import { Library } from './library';
export interface Member {
username: string;
lastActive: string; // datetime
created: string; // datetime
isAdmin: boolean;
roles: string[];
libraries: Library[];
}

View file

@ -0,0 +1,11 @@
export interface Pagination {
currentPage: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
}
export class PaginatedResult<T> {
result!: T;
pagination!: Pagination;
}

View file

@ -0,0 +1,10 @@
export enum PersonRole {
Other = 0,
Author = 1,
Artist = 2
}
export interface Person {
name: string;
role: PersonRole;
}

View file

@ -0,0 +1,5 @@
export enum PageSplitOption {
SplitLeftToRight = 0,
SplitRightToLeft = 1,
NoSplit = 2
}

View file

@ -0,0 +1,30 @@
import { PageSplitOption } from './page-split-option';
import { READER_MODE } from './reader-mode';
import { ReadingDirection } from './reading-direction';
import { ScalingOption } from './scaling-option';
export interface Preferences {
// Manga Reader
readingDirection: ReadingDirection;
scalingOption: ScalingOption;
pageSplitOption: PageSplitOption;
readerMode: READER_MODE;
autoCloseMenu: boolean;
// Book Reader
bookReaderDarkMode: boolean;
bookReaderMargin: number;
bookReaderLineSpacing: number;
bookReaderFontSize: number;
bookReaderFontFamily: string;
bookReaderTapToPaginate: boolean;
bookReaderReadingDirection: ReadingDirection;
// Global
siteDarkMode: boolean;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}/*, {text: 'Webtoon', value: READER_MODE.WEBTOON}*/];

View file

@ -0,0 +1,14 @@
export enum READER_MODE {
/**
* Manga default left/right to page
*/
MANGA_LR = 0,
/**
* Manga up and down to page
*/
MANGA_UD = 1,
/**
* Webtoon reading (scroll) with optional areas to tap
*/
WEBTOON = 2
}

View file

@ -0,0 +1,4 @@
export enum ReadingDirection {
LeftToRight = 0,
RightToLeft = 1
}

View file

@ -0,0 +1,6 @@
export enum ScalingOption {
FitToHeight = 0,
FitToWidth = 1,
Original = 2,
Automatic = 3
}

View file

@ -0,0 +1,9 @@
export interface SearchResult {
seriesId: number;
libraryId: number;
libraryName: string;
name: string;
originalName: string;
sortName: string;
coverImage: string; // byte64 encoded
}

View file

@ -0,0 +1,10 @@
import { CollectionTag } from "./collection-tag";
import { Person } from "./person";
export interface SeriesMetadata {
publisher: string;
genres: Array<string>;
tags: Array<CollectionTag>;
persons: Array<Person>;
seriesId: number;
}

View file

@ -0,0 +1,18 @@
import { Volume } from './volume';
export interface Series {
id: number;
name: string;
originalName: string; // This is not shown to user
localizedName: string;
sortName: string;
summary: string;
coverImage: string;
volumes: Volume[];
pages: number; // Total pages in series
pagesRead: number; // Total pages the logged in user has read
userRating: number; // User rating
userReview: string; // User review
libraryId: number;
created: string; // DateTime when entity was created
}

View file

@ -0,0 +1,9 @@
import { Preferences } from './preferences/preferences';
// This interface is only used for login and storing/retreiving JWT from local storage
export interface User {
username: string;
token: string;
roles: string[];
preferences: Preferences;
}

View file

@ -0,0 +1,13 @@
import { Chapter } from './chapter';
export interface Volume {
id: number;
number: number;
name: string;
coverImage: string;
created: string;
lastModified: string;
pages: number;
pagesRead: number;
chapters?: Array<Chapter>;
}

View file

@ -0,0 +1,126 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, OnDestroy } from '@angular/core';
import { Observable, ReplaySubject, Subject } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences';
import { User } from '../_models/user';
import * as Sentry from "@sentry/angular";
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root'
})
export class AccountService implements OnDestroy {
baseUrl = environment.apiUrl;
userKey = 'kavita-user';
currentUser: User | undefined;
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User>(1);
currentUser$ = this.currentUserSource.asObservable();
private readonly onDestroy = new Subject<void>();
constructor(private httpClient: HttpClient, private router: Router) {}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
hasAdminRole(user: User) {
return user && user.roles.includes('Admin');
}
hasDownloadRole(user: User) {
return user && user.roles.includes('Download');
}
getRoles() {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
}
login(model: any): Observable<any> {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
map((response: User) => {
const user = response;
if (user) {
this.setCurrentUser(user);
}
}),
takeUntil(this.onDestroy)
);
}
setCurrentUser(user?: User) {
if (user) {
user.roles = [];
const roles = this.getDecodedToken(user.token).role;
Array.isArray(roles) ? user.roles = roles : user.roles.push(roles);
Sentry.setContext('admin', {'admin': this.hasAdminRole(user)});
Sentry.configureScope(scope => {
scope.setUser({
username: user.username
});
});
localStorage.setItem(this.userKey, JSON.stringify(user));
}
this.currentUserSource.next(user);
this.currentUser = user;
}
logout() {
localStorage.removeItem(this.userKey);
this.currentUserSource.next(undefined);
this.currentUser = undefined;
// Upon logout, perform redirection
this.router.navigateByUrl('/login');
}
register(model: {username: string, password: string, isAdmin?: boolean}) {
if (!model.hasOwnProperty('isAdmin')) {
model.isAdmin = false;
}
return this.httpClient.post<User>(this.baseUrl + 'account/register', model).pipe(
map((user: User) => {
return user;
}),
takeUntil(this.onDestroy)
);
}
getDecodedToken(token: string) {
return JSON.parse(atob(token.split('.')[1]));
}
resetPassword(username: string, password: string) {
return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password}, {responseType: 'json' as 'text'});
}
updatePreferences(userPreferences: Preferences) {
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
if (this.currentUser !== undefined || this.currentUser != null) {
this.currentUser.preferences = settings;
this.setCurrentUser(this.currentUser);
}
return settings;
}), takeUntil(this.onDestroy));
}
getUserFromLocalStorage(): User | undefined {
const userString = localStorage.getItem(this.userKey);
if (userString) {
return JSON.parse(userString)
};
return undefined;
}
}

View file

@ -0,0 +1,202 @@
import { Injectable } from '@angular/core';
import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag';
import { Library } from '../_models/library';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
export enum Action {
MarkAsRead = 0,
MarkAsUnread = 1,
ScanLibrary = 2,
Delete = 3,
Edit = 4,
Info = 5,
RefreshMetadata = 6,
Download = 7
}
export interface ActionItem<T> {
title: string;
action: Action;
callback: (action: Action, data: T) => void;
}
@Injectable({
providedIn: 'root'
})
export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = [];
seriesActions: Array<ActionItem<Series>> = [];
volumeActions: Array<ActionItem<Volume>> = [];
chapterActions: Array<ActionItem<Chapter>> = [];
collectionTagActions: Array<ActionItem<CollectionTag>> = [];
isAdmin = false;
hasDownloadRole = false;
constructor(private accountService: AccountService) {
this.accountService.currentUser$.subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadRole = this.accountService.hasDownloadRole(user);
} else {
this._resetActions();
return; // If user is logged out, we don't need to do anything
}
this._resetActions();
if (this.isAdmin) {
this.collectionTagActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback
});
this.seriesActions.push({
action: Action.ScanLibrary,
title: 'Scan Series',
callback: this.dummyCallback
});
this.seriesActions.push({
action: Action.RefreshMetadata,
title: 'Refresh Metadata',
callback: this.dummyCallback
});
this.seriesActions.push({
action: Action.Delete,
title: 'Delete',
callback: this.dummyCallback
});
this.seriesActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback
});
this.libraryActions.push({
action: Action.ScanLibrary,
title: 'Scan Library',
callback: this.dummyCallback
});
this.libraryActions.push({
action: Action.RefreshMetadata,
title: 'Refresh Metadata',
callback: this.dummyCallback
});
}
if (this.hasDownloadRole || this.isAdmin) {
this.volumeActions.push({
action: Action.Download,
title: 'Download',
callback: this.dummyCallback
});
this.chapterActions.push({
action: Action.Download,
title: 'Download',
callback: this.dummyCallback
});
}
});
}
getLibraryActions(callback: (action: Action, library: Library) => void) {
this.libraryActions.forEach(action => action.callback = callback);
return this.libraryActions;
}
getSeriesActions(callback: (action: Action, series: Series) => void) {
this.seriesActions.forEach(action => action.callback = callback);
return this.seriesActions;
}
getVolumeActions(callback: (action: Action, volume: Volume) => void) {
this.volumeActions.forEach(action => action.callback = callback);
return this.volumeActions;
}
getChapterActions(callback: (action: Action, chapter: Chapter) => void) {
this.chapterActions.forEach(action => action.callback = callback);
return this.chapterActions;
}
getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) {
this.collectionTagActions.forEach(action => action.callback = callback);
return this.collectionTagActions;
}
dummyCallback(action: Action, data: any) {}
_resetActions() {
this.libraryActions = [];
this.collectionTagActions = [];
this.seriesActions = [
{
action: Action.MarkAsRead,
title: 'Mark as Read',
callback: this.dummyCallback
},
{
action: Action.MarkAsUnread,
title: 'Mark as Unread',
callback: this.dummyCallback
}
];
this.volumeActions = [
{
action: Action.MarkAsRead,
title: 'Mark as Read',
callback: this.dummyCallback
},
{
action: Action.MarkAsUnread,
title: 'Mark as Unread',
callback: this.dummyCallback
}
];
this.chapterActions = [
{
action: Action.MarkAsRead,
title: 'Mark as Read',
callback: this.dummyCallback
},
{
action: Action.MarkAsUnread,
title: 'Mark as Unread',
callback: this.dummyCallback
}
];
this.volumeActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback
});
this.chapterActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback
});
}
}

View file

@ -0,0 +1,199 @@
import { Injectable, OnDestroy } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { Chapter } from '../_models/chapter';
import { Library } from '../_models/library';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { LibraryService } from './library.service';
import { ReaderService } from './reader.service';
import { SeriesService } from './series.service';
export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
export type VolumeActionCallback = (volume: Volume) => void;
export type ChapterActionCallback = (chapter: Chapter) => void;
/**
* Responsible for executing actions
*/
@Injectable({
providedIn: 'root'
})
export class ActionService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService) { }
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
/**
* Request a file scan for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
*/
scanLibrary(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
this.libraryService.scan(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Scan started for ' + library.name);
if (callback) {
callback(library);
}
});
}
/**
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
*/
refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Scan started for ' + library.name);
if (callback) {
callback(library);
}
});
}
/**
* Mark a series as read; updates the series pagesRead
* @param series Series, must have id and name populated
* @param callback Optional callback to perform actions after API completes
*/
markSeriesAsRead(series: Series, callback?: SeriesActionCallback) {
this.seriesService.markRead(series.id).pipe(take(1)).subscribe(res => {
series.pagesRead = series.pages;
this.toastr.success(series.name + ' is now read');
if (callback) {
callback(series);
}
});
}
/**
* Mark a series as unread; updates the series pagesRead
* @param series Series, must have id and name populated
* @param callback Optional callback to perform actions after API completes
*/
markSeriesAsUnread(series: Series, callback?: SeriesActionCallback) {
this.seriesService.markUnread(series.id).pipe(take(1)).subscribe(res => {
series.pagesRead = 0;
this.toastr.success(series.name + ' is now unread');
if (callback) {
callback(series);
}
});
}
/**
* Start a file scan for a Series (currently just does the library not the series directly)
* @param series Series, must have libraryId and name populated
* @param callback Optional callback to perform actions after API completes
*/
scanSeries(series: Series, callback?: SeriesActionCallback) {
this.seriesService.scan(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Scan started for ' + series.name);
if (callback) {
callback(series);
}
});
}
/**
* Start a metadata refresh for a Series
* @param series Series, must have libraryId, id and name populated
* @param callback Optional callback to perform actions after API completes
*/
refreshMetdata(series: Series, callback?: SeriesActionCallback) {
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Refresh started for ' + series.name);
if (callback) {
callback(series);
}
});
}
/**
* Mark all chapters and the volume as Read
* @param seriesId Series Id
* @param volume Volume, should have id, chapters and pagesRead populated
* @param callback Optional callback to perform actions after API completes
*/
markVolumeAsRead(seriesId: number, volume: Volume, callback?: VolumeActionCallback) {
this.readerService.markVolumeRead(seriesId, volume.id).pipe(take(1)).subscribe(() => {
volume.pagesRead = volume.pages;
volume.chapters?.forEach(c => c.pagesRead = c.pages);
this.toastr.success('Marked as Read');
if (callback) {
callback(volume);
}
});
}
/**
* Mark all chapters and the volume as unread
* @param seriesId Series Id
* @param volume Volume, should have id, chapters and pagesRead populated
* @param callback Optional callback to perform actions after API completes
*/
markVolumeAsUnread(seriesId: number, volume: Volume, callback?: VolumeActionCallback) {
forkJoin(volume.chapters?.map(chapter => this.readerService.bookmark(seriesId, volume.id, chapter.id, 0))).pipe(takeUntil(this.onDestroy)).subscribe(results => {
volume.pagesRead = 0;
volume.chapters?.forEach(c => c.pagesRead = 0);
this.toastr.success('Marked as Unread');
if (callback) {
callback(volume);
}
});
}
/**
* Mark a chapter as read
* @param seriesId Series Id
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsRead(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.bookmark(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
chapter.pagesRead = chapter.pages;
this.toastr.success('Marked as Read');
if (callback) {
callback(chapter);
}
});
}
/**
* Mark a chapter as unread
* @param seriesId Series Id
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.bookmark(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
chapter.pagesRead = 0;
this.toastr.success('Marked as unread');
if (callback) {
callback(chapter);
}
});
}
}

View file

@ -0,0 +1,38 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { CollectionTag } from '../_models/collection-tag';
import { ImageService } from './image.service';
@Injectable({
providedIn: 'root'
})
export class CollectionTagService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
allTags() {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/').pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
return tags;
}));
}
search(query: string) {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
return tags;
}));
}
updateTag(tag: CollectionTag) {
return this.httpClient.post(this.baseUrl + 'collection/update', tag, {responseType: 'text' as 'json'});
}
updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array<number>) {
return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, {responseType: 'text' as 'json'});
}
}

View file

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { NavService } from './nav.service';
@Injectable({
providedIn: 'root'
})
export class ImageService {
baseUrl = environment.apiUrl;
public placeholderImage = 'assets/images/image-placeholder-min.png';
public errorImage = 'assets/images/error-placeholder2-min.png';
constructor(private navSerivce: NavService) {
this.navSerivce.darkMode$.subscribe(res => {
if (res) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
} else {
this.placeholderImage = 'assets/images/image-placeholder-min.png';
this.errorImage = 'assets/images/error-placeholder2-min.png';
}
});
}
getVolumeCoverImage(volumeId: number) {
return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId;
}
getSeriesCoverImage(seriesId: number) {
return this.baseUrl + 'image/series-cover?seriesId=' + seriesId;
}
getCollectionCoverImage(collectionTagId: number) {
return this.baseUrl + 'image/collection-cover?collectionTagId=' + collectionTagId;
}
getChapterCoverImage(chapterId: number) {
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId;
}
}

View file

@ -0,0 +1,89 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Library, LibraryType } from '../_models/library';
import { SearchResult } from '../_models/search-result';
@Injectable({
providedIn: 'root'
})
export class LibraryService {
baseUrl = environment.apiUrl;
libraryNames: {[key:number]: string} | undefined = undefined;
constructor(private httpClient: HttpClient) {}
getLibraryNames() {
if (this.libraryNames != undefined) {
return of(this.libraryNames);
}
return this.httpClient.get<Library[]>(this.baseUrl + 'library').pipe(map(l => {
this.libraryNames = {};
l.forEach(lib => {
if (this.libraryNames !== undefined) {
this.libraryNames[lib.id] = lib.name;
}
});
return this.libraryNames;
}));
}
listDirectories(rootPath: string) {
let query = '';
if (rootPath !== undefined && rootPath.length > 0) {
query = '?path=' + rootPath;
}
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
}
getLibraries() {
return this.httpClient.get<Library[]>(this.baseUrl + 'library');
}
getLibrariesForMember() {
return this.httpClient.get<Library[]>(this.baseUrl + 'library/libraries');
}
updateLibrariesForMember(username: string, selectedLibraries: Library[]) {
return this.httpClient.post(this.baseUrl + 'library/grant-access', {username, selectedLibraries});
}
scan(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId, {});
}
refreshMetadata(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId, {});
}
create(model: {name: string, type: number, folders: string[]}) {
return this.httpClient.post(this.baseUrl + 'library/create', model);
}
delete(libraryId: number) {
return this.httpClient.delete(this.baseUrl + 'library/delete?libraryId=' + libraryId, {});
}
update(model: {name: string, folders: string[], id: number}) {
return this.httpClient.post(this.baseUrl + 'library/update', model);
}
getLibraryType(libraryId: number) {
// TODO: Cache this in browser
return this.httpClient.get<LibraryType>(this.baseUrl + 'library/type?libraryId=' + libraryId);
}
search(term: string) {
if (term === '') {
return of([]);
}
return this.httpClient.get<SearchResult[]>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
}
}

View file

@ -0,0 +1,38 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { Member } from '../_models/member';
@Injectable({
providedIn: 'root'
})
export class MemberService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getMembers() {
return this.httpClient.get<Member[]>(this.baseUrl + 'users');
}
adminExists() {
return this.httpClient.get<boolean>(this.baseUrl + 'admin/exists');
}
deleteMember(username: string) {
return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + username);
}
hasLibraryAccess(libraryId: number) {
return this.httpClient.get<boolean>(this.baseUrl + 'users/has-library-access?libraryId=' + libraryId);
}
hasReadingProgress(librayId: number) {
return this.httpClient.get<boolean>(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId);
}
updateMemberRoles(username: string, roles: string[]) {
return this.httpClient.post(this.baseUrl + 'account/update-rbs', {username, roles});
}
}

View file

@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class NavService {
private navbarVisibleSource = new ReplaySubject<boolean>(1);
navbarVisible$ = this.navbarVisibleSource.asObservable();
private darkMode: boolean = true;
private darkModeSource = new ReplaySubject<boolean>(1);
darkMode$ = this.darkModeSource.asObservable();
constructor() {
this.showNavBar();
}
showNavBar() {
this.navbarVisibleSource.next(true);
}
hideNavBar() {
this.navbarVisibleSource.next(false);
}
toggleDarkMode() {
this.darkMode = !this.darkMode;
this.darkModeSource.next(this.darkMode);
}
setDarkMode(mode: boolean) {
this.darkMode = mode;
this.darkModeSource.next(this.darkMode);
}
}

View file

@ -0,0 +1,107 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
//import * as console.log from 'console.log';
import { environment } from 'src/environments/environment';
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
import { UtilityService } from '../shared/_services/utility.service';
import { Bookmark } from '../_models/bookmark';
import { Chapter } from '../_models/chapter';
import { Volume } from '../_models/volume';
@Injectable({
providedIn: 'root'
})
export class ReaderService {
baseUrl = environment.apiUrl;
// Override background color for reader and restore it onDestroy
private originalBodyColor!: string;
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getBookmark(chapterId: number) {
return this.httpClient.get<Bookmark>(this.baseUrl + 'reader/get-bookmark?chapterId=' + chapterId);
}
getPageUrl(chapterId: number, page: number) {
return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page;
}
getChapterInfo(chapterId: number) {
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId);
}
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
}
markVolumeRead(seriesId: number, volumeId: number) {
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
}
getNextChapter(seriesId: number, volumeId: number, currentChapterId: number) {
return this.httpClient.get<number>(this.baseUrl + 'reader/next-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '&currentChapterId=' + currentChapterId);
}
getPrevChapter(seriesId: number, volumeId: number, currentChapterId: number) {
return this.httpClient.get<number>(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '&currentChapterId=' + currentChapterId);
}
getCurrentChapter(volumes: Array<Volume>): Chapter {
let currentlyReadingChapter: Chapter | undefined = undefined;
const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); // changed from === 0 to != 0
for (const c of chapters) {
if (c.pagesRead < c.pages) {
currentlyReadingChapter = c;
break;
}
}
if (currentlyReadingChapter === undefined) {
// Check if there are specials we can load:
const specials = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
for (const c of specials) {
if (c.pagesRead < c.pages) {
currentlyReadingChapter = c;
break;
}
}
if (currentlyReadingChapter === undefined) {
// Default to first chapter
currentlyReadingChapter = chapters[0];
}
}
return currentlyReadingChapter;
}
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/
setOverrideStyles() {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
this.originalBodyColor = bodyNode.style.background;
bodyNode.setAttribute('style', 'background-color: black !important');
}
}
resetOverrideStyles() {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
bodyNode.style.background = this.originalBodyColor;
}
}
/**
* Parses out the page number from a Image src url
* @param imageSrc Src attribute of Image
* @returns
*/
imageUrlToPageNum(imageSrc: string) {
if (imageSrc === undefined || imageSrc === '') { return -1; }
return parseInt(imageSrc.split('&page=')[1], 10);
}
}

View file

@ -0,0 +1,163 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter';
import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { SeriesMetadata } from '../_models/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
@Injectable({
providedIn: 'root'
})
export class SeriesService {
baseUrl = environment.apiUrl;
paginatedResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
_cachePaginatedResults(response: any, paginatedVariable: PaginatedResult<any[]>) {
if (response.body === null) {
paginatedVariable.result = [];
} else {
paginatedVariable.result = response.body;
}
const pageHeader = response.headers.get('Pagination');
if (pageHeader !== null) {
paginatedVariable.pagination = JSON.parse(pageHeader);
}
return paginatedVariable;
}
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, {observe: 'response', params}).pipe(
map((response: any) => {
return this._cachePaginatedResults(response, this.paginatedResults);
})
);
}
getSeries(seriesId: number) {
return this.httpClient.get<Series>(this.baseUrl + 'series/' + seriesId);
}
getVolumes(seriesId: number) {
return this.httpClient.get<Volume[]>(this.baseUrl + 'series/volumes?seriesId=' + seriesId);
}
getVolume(volumeId: number) {
return this.httpClient.get<Volume>(this.baseUrl + 'series/volume?volumeId=' + volumeId);
}
getChapter(chapterId: number) {
return this.httpClient.get<Chapter>(this.baseUrl + 'series/chapter?chapterId=' + chapterId);
}
getData(id: number) {
return of(id);
}
delete(seriesId: number) {
return this.httpClient.delete<boolean>(this.baseUrl + 'series/' + seriesId);
}
updateRating(seriesId: number, userRating: number, userReview: string) {
return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating, userReview});
}
updateSeries(model: any) {
return this.httpClient.post(this.baseUrl + 'series/', model);
}
markRead(seriesId: number) {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-read', {seriesId});
}
markUnread(seriesId: number) {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId});
}
getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<Series[]>(this.baseUrl + 'series/recently-added', {observe: 'response', params}).pipe(
map((response: any) => {
return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults);
})
);
}
getInProgress(libraryId: number = 0) {
return this.httpClient.get<Series[]>(this.baseUrl + 'series/in-progress?libraryId=' + libraryId).pipe(map(series => {
series.forEach(s => s.coverImage = this.imageService.getSeriesCoverImage(s.id));
return series;
}));
}
getContinueReading(libraryId: number = 0) {
return this.httpClient.get<InProgressChapter[]>(this.baseUrl + 'series/continue-reading?libraryId=' + libraryId);
}
refreshMetadata(series: Series) {
return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id});
}
scan(libraryId: number, seriesId: number) {
return this.httpClient.post(this.baseUrl + 'series/scan', {libraryId: libraryId, seriesId: seriesId});
}
getMetadata(seriesId: number) {
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
items?.tags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
return items;
}));
}
updateMetadata(seriesMetadata: SeriesMetadata, tags: CollectionTag[]) {
const data = {
seriesMetadata,
tags
};
return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'});
}
getSeriesForTag(collectionTagId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
// NOTE: I'm not sure the paginated result is doing anything
// if (this.paginatedSeriesForTagsResults?.pagination !== undefined && this.paginatedSeriesForTagsResults?.pagination?.currentPage === pageNum) {
// return of(this.paginatedSeriesForTagsResults);
// }
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe(
map((response: any) => {
return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults);
})
);
}
_addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) {
if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) {
params = params.append('pageNumber', pageNum + '');
params = params.append('pageSize', itemsPerPage + '');
}
return params;
}
}

View file

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { ServerInfo } from '../admin/_models/server-info';
@Injectable({
providedIn: 'root'
})
export class ServerService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
restart() {
return this.httpClient.post(this.baseUrl + 'server/restart', {});
}
fetchLogs() {
return this.httpClient.get(this.baseUrl + 'server/logs', {responseType: 'blob' as 'text'});
}
getServerInfo() {
return this.httpClient.get<ServerInfo>(this.baseUrl + 'server/server-info');
}
}

View file

@ -0,0 +1,18 @@
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { environment } from "src/environments/environment";
import { ClientInfo } from "../_models/client-info";
@Injectable({
providedIn: 'root'
})
export class StatsService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
public sendClientInfo(clientInfo: ClientInfo) {
return this.httpClient.post(this.baseUrl + 'stats/client-info', clientInfo);
}
}

View file

@ -0,0 +1,54 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Choose a Directory</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="filter">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="false" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
</div>
</div>
<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">
{{route}}
</ng-container>
<ng-template #nonActive>
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
</ng-template>
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb</div>
</ng-template>
</nav>
<ul class="list-group">
<div class="list-group-item list-group-item-action">
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
<i class="fa fa-arrow-left mr-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-right" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
<ul class="list-group scrollable">
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
<span>{{getStem(folder)}}</span>
<button type="button" class="btn btn-primary float-right" (click)="shareFolder(folder, $event)">Share</button>
</button>
<div class="list-group-item text-center" *ngIf="folders.length === 0">
There are no folders here
</div>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>

View file

@ -0,0 +1,15 @@
$breadcrumb-divider: quote(">");
.breadcrumb-item + .breadcrumb-item::before {
content: $breadcrumb-divider;
}
.scrollable {
overflow-y: auto;
max-height: 400px;
}
.btn-outline-secondary {
border: 1px solid #ced4da;
}

View file

@ -0,0 +1,110 @@
import { Component, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Stack } from 'src/app/shared/data-structures/stack';
import { LibraryService } from '../../../_services/library.service';
export interface DirectoryPickerResult {
success: boolean;
folderPath: string;
}
@Component({
selector: 'app-directory-picker',
templateUrl: './directory-picker.component.html',
styleUrls: ['./directory-picker.component.scss']
})
export class DirectoryPickerComponent implements OnInit {
currentRoot = '';
folders: string[] = [];
routeStack: Stack<string> = new Stack<string>();
filterQuery: string = '';
constructor(public modal: NgbActiveModal, private libraryService: LibraryService) {
}
ngOnInit(): void {
this.loadChildren(this.currentRoot);
}
filterFolder = (folder: string) => {
return folder.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
}
selectNode(folderName: string) {
this.currentRoot = folderName;
this.routeStack.push(folderName);
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
}
goBack() {
// BUG: When Going back to initial listing, this code gets stuck on first drive
this.routeStack.pop();
const stackPeek = this.routeStack.peek();
if (stackPeek !== undefined) {
this.currentRoot = stackPeek;
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
} else {
this.currentRoot = '';
this.loadChildren(this.currentRoot);
}
}
loadChildren(path: string) {
this.libraryService.listDirectories(path).subscribe(folders => {
this.folders = folders;
}, err => {
// If there was an error, pop off last directory added to stack
this.routeStack.pop();
});
}
shareFolder(folderName: string, event: any) {
event.preventDefault();
event.stopPropagation();
let fullPath = folderName;
if (this.routeStack.items.length > 0) {
const pathJoin = this.routeStack.items.join('/');
fullPath = pathJoin + ((pathJoin.endsWith('/') || pathJoin.endsWith('\\')) ? '' : '/') + folderName;
}
this.modal.close({success: true, folderPath: fullPath});
}
close() {
this.modal.close({success: false, folderPath: undefined});
}
getStem(path: string): string {
const lastPath = this.routeStack.peek();
if (lastPath && lastPath != path) {
let replaced = path.replace(lastPath, '');
if (replaced.startsWith('/') || replaced.startsWith('\\')) {
replaced = replaced.substr(1, replaced.length);
}
return replaced;
}
return path;
}
navigateTo(index: number) {
const numberOfPops = this.routeStack.items.length - index;
if (this.routeStack.items.length - numberOfPops > this.routeStack.items.length) {
this.routeStack.items = [];
}
for (let i = 0; i < numberOfPops; i++) {
this.routeStack.pop();
}
this.loadChildren(this.routeStack.peek() || '');
}
}

View file

@ -0,0 +1,23 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member?.username}}'s Roles</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<ul class="list-group">
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{role.data}}" class="form-check-input"
[(ngModel)]="role.selected" name="library">
<label attr.for="library-{{i}}" class="form-check-label">{{role.data}}</label>
</div>
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View file

@ -0,0 +1,70 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/member';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
@Component({
selector: 'app-edit-rbs-modal',
templateUrl: './edit-rbs-modal.component.html',
styleUrls: ['./edit-rbs-modal.component.scss']
})
export class EditRbsModalComponent implements OnInit {
@Input() member: Member | undefined;
allRoles: string[] = [];
selectedRoles: Array<{selected: boolean, data: string}> = [];
constructor(public modal: NgbActiveModal, private accountService: AccountService, private memberService: MemberService) { }
ngOnInit(): void {
this.accountService.getRoles().subscribe(roles => {
roles = roles.filter(item => item != 'Admin' && item != 'Pleb'); // Do not allow the user to modify Account RBS
this.allRoles = roles;
this.selectedRoles = roles.map(item => {
return {selected: false, data: item};
});
this.preselect();
});
}
close() {
this.modal.close(false);
}
save() {
if (this.member?.username === undefined) {
return;
}
const selectedRoles = this.selectedRoles.filter(item => item.selected).map(item => item.data);
this.memberService.updateMemberRoles(this.member?.username, selectedRoles).subscribe(() => {
if (this.member) {
this.member.roles = selectedRoles;
}
this.modal.close(true);
});
}
reset() {
this.selectedRoles = this.allRoles.map(item => {
return {selected: false, data: item};
});
this.preselect();
}
preselect() {
if (this.member !== undefined) {
this.member.roles.forEach(role => {
const foundRole = this.selectedRoles.filter(item => item.data === role);
if (foundRole.length > 0) {
foundRole[0].selected = true;
}
});
}
}
}

View file

@ -0,0 +1,23 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Library Access</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="list-group">
<li class="list-group-item" *ngFor="let library of selectedLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{library.data.name}}" class="form-check-input"
[(ngModel)]="library.selected" name="library">
<label attr.for="library-{{i}}" class="form-check-label">{{library.data.name}}</label>
</div>
</li>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View file

@ -0,0 +1,70 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member';
import { LibraryService } from 'src/app/_services/library.service';
@Component({
selector: 'app-library-access-modal',
templateUrl: './library-access-modal.component.html',
styleUrls: ['./library-access-modal.component.scss']
})
export class LibraryAccessModalComponent implements OnInit {
@Input() member: Member | undefined;
allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {
this.allLibraries = libs;
this.selectedLibraries = libs.map(item => {
return {selected: false, data: item};
});
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
});
}
});
}
close() {
this.modal.close(false);
}
save() {
if (this.member?.username === undefined) {
return;
}
const selectedLibraries = this.selectedLibraries.filter(item => item.selected).map(item => item.data);
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
this.modal.close(true);
});
}
reset() {
this.selectedLibraries = this.allLibraries.map(item => {
return {selected: false, data: item};
});
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
});
}
}
}

View file

@ -0,0 +1,40 @@
<form [formGroup]="libraryForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{this.library !== undefined ? 'Edit' : 'New'}} Library</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="form-group">
<label for="library-name">Name</label>
<input id="library-name" class="form-control" formControlName="name" type="text">
</div>
<div class="form-group">
<label for="library-type">Type</label>
<select class="form-control" id="library-type" formControlName="type" [attr.disabled]="this.library">
<option [value]="i" *ngFor="let opt of libraryTypes; let i = index">{{opt}}</option>
</select>
</div>
<h4>Folders <button type="button" class="btn float-right btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
<ul class="list-group" style="width: 100%">
<li class="list-group-item" *ngFor="let folder of selectedFolders; let i = index">
{{folder}}
<button class="btn float-right btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="submit" class="btn btn-primary" (click)="submitLibrary()" [disabled]="!libraryForm.dirty && !madeChanges">Save</button>
</div>
</form>

View file

@ -0,0 +1,104 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { SettingsService } from '../../settings.service';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../directory-picker/directory-picker.component';
@Component({
selector: 'app-library-editor-modal',
templateUrl: './library-editor-modal.component.html',
styleUrls: ['./library-editor-modal.component.scss']
})
export class LibraryEditorModalComponent implements OnInit {
@Input() library: Library | undefined = undefined;
libraryForm: FormGroup = new FormGroup({
name: new FormControl('', [Validators.required]),
type: new FormControl(0, [Validators.required])
});
selectedFolders: string[] = [];
errorMessage = '';
madeChanges = false;
libraryTypes: string[] = []
constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService) { }
ngOnInit(): void {
this.settingService.getLibraryTypes().subscribe((types) => {
this.libraryTypes = types;
});
this.setValues();
}
removeFolder(folder: string) {
this.selectedFolders = this.selectedFolders.filter(item => item !== folder);
this.madeChanges = true;
}
submitLibrary() {
const model = this.libraryForm.value;
model.folders = this.selectedFolders;
if (this.libraryForm.errors) {
return;
}
if (this.library !== undefined) {
model.id = this.library.id;
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
this.libraryService.update(model).subscribe(() => {
this.close(true);
}, err => {
this.errorMessage = err;
});
} else {
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
this.libraryService.create(model).subscribe(() => {
this.close(true);
}, err => {
this.errorMessage = err;
});
}
}
close(returnVal= false) {
const model = this.libraryForm.value;
this.modal.close(returnVal);
}
reset() {
this.setValues();
}
setValues() {
if (this.library !== undefined) {
this.libraryForm.get('name')?.setValue(this.library.name);
this.libraryForm.get('type')?.setValue(this.library.type);
this.selectedFolders = this.library.folders;
this.madeChanges = false;
}
}
openDirectoryPicker() {
const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' });
modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => {
if (closeResult.success) {
if (!this.selectedFolders.includes(closeResult.folderPath)) {
this.selectedFolders.push(closeResult.folderPath);
this.madeChanges = true;
}
}
});
}
}

View file

@ -0,0 +1,21 @@
<form [formGroup]="resetPasswordForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Reset {{member.username | titlecase}}'s Password</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="form-group">
<label for="password">New Password</label>
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="resetPasswordForm.value.password.length === 0" (click)="save()">Save</button>
</div>
</form>

View file

@ -0,0 +1,36 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/member';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
@Component({
selector: 'app-reset-password-modal',
templateUrl: './reset-password-modal.component.html',
styleUrls: ['./reset-password-modal.component.scss']
})
export class ResetPasswordModalComponent implements OnInit {
@Input() member!: Member;
errorMessage = '';
resetPasswordForm: FormGroup = new FormGroup({
password: new FormControl('', [Validators.required]),
});
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
ngOnInit(): void {
}
save() {
this.accountService.resetPassword(this.member.username, this.resetPasswordForm.value.password).subscribe(() => {
this.modal.close();
});
}
close() {
this.modal.close();
}
}

View file

@ -0,0 +1,8 @@
export interface ServerInfo {
os: string;
dotNetVersion: string;
runTimeVersion: string;
kavitaVersion: string;
buildBranch: string;
culture: string;
}

View file

@ -0,0 +1,8 @@
export interface ServerSettings {
cacheDirectory: string;
taskScan: string;
taskBackup: string;
loggingLevel: string;
port: number;
allowStatCollection: boolean;
}

View file

@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AdminGuard } from '../_guards/admin.guard';
import { DashboardComponent } from './dashboard/dashboard.component';
const routes: Routes = [
{path: '**', component: DashboardComponent, pathMatch: 'full'},
{
runGuardsAndResolvers: 'always',
canActivate: [AdminGuard],
children: [
{path: '/dashboard', component: DashboardComponent},
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class AdminRoutingModule { }

View file

@ -0,0 +1,47 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ManageLibraryComponent } from './manage-library/manage-library.component';
import { ManageUsersComponent } from './manage-users/manage-users.component';
import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.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 { FilterPipe } from './filter.pipe';
import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component';
import { ManageSystemComponent } from './manage-system/manage-system.component';
@NgModule({
declarations: [
ManageUsersComponent,
DashboardComponent,
ManageLibraryComponent,
LibraryEditorModalComponent,
LibraryAccessModalComponent,
DirectoryPickerComponent,
ResetPasswordModalComponent,
ManageSettingsComponent,
FilterPipe,
EditRbsModalComponent,
ManageSystemComponent
],
imports: [
CommonModule,
AdminRoutingModule,
ReactiveFormsModule,
FormsModule,
NgbNavModule,
NgbTooltipModule,
SharedModule,
],
providers: []
})
export class AdminModule { }

View file

@ -0,0 +1,29 @@
<div class="container">
<h2>Admin Dashboard</h2>
<div class="float-right">
<button class="btn btn-secondary" (click)="fetchLogs()">Download Logs</button>
</div>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === ''">
<app-manage-settings></app-manage-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === 'users'">
<app-manage-users></app-manage-users>
</ng-container>
<ng-container *ngIf="tab.fragment === 'libraries'">
<app-manage-library></app-manage-library>
</ng-container>
<ng-container *ngIf="tab.fragment === 'system'">
<app-manage-system></app-manage-system>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>
</div>

View file

@ -0,0 +1,52 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ServerService } from 'src/app/_services/server.service';
import { saveAs } from 'file-saver';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
tabs: Array<{title: string, fragment: string}> = [
{title: 'General', fragment: ''},
{title: 'Users', fragment: 'users'},
{title: 'Libraries', fragment: 'libraries'},
{title: 'System', fragment: 'system'}
];
counter = this.tabs.length + 1;
active = this.tabs[0];
constructor(public route: ActivatedRoute, private serverService: ServerService, private toastr: ToastrService) {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {
this.active = tab[0];
} else {
this.active = this.tabs[0]; // Default to first tab
}
});
}
ngOnInit() {}
restartServer() {
this.serverService.restart().subscribe(() => {
setTimeout(() => this.toastr.success('Please reload.'), 1000);
});
}
fetchLogs() {
this.serverService.fetchLogs().subscribe(res => {
const blob = new Blob([res], {type: 'text/plain;charset=utf-8'});
saveAs(blob, 'kavita.zip');
});
}
}

View file

@ -0,0 +1,16 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'filter',
pure: false
})
export class FilterPipe implements PipeTransform {
transform(items: any[], callback: (item: any) => boolean): any {
if (!items || !callback) {
return items;
}
return items.filter(item => callback(item));
}
}

View file

@ -0,0 +1,33 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Libraries</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="addLibrary()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add Library</span></button></div>
</div>
<ul class="list-group" *ngIf="!createLibraryToggle; else createLibrary">
<li *ngFor="let library of libraries" class="list-group-item">
<div>
<h4>
{{library.name | titlecase}}
<div class="float-right">
<button class="btn btn-secondary mr-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
<button class="btn btn-danger mr-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | titlecase}}"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{library.name | titlecase}}"></i></button>
</div>
</h4>
</div>
<div>Type: {{libraryType(library.type)}}</div>
<div>Shared Folders: {{library.folders.length + ' folders'}}</div>
</li>
<li *ngIf="loading" class="list-group-item">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item" *ngIf="libraries.length === 0 && !loading">
There are no libraries. Try creating one.
</li>
</ul>
<ng-template #createLibrary>
<app-library-editor-modal></app-library-editor-modal>
</ng-template>
</div>

View file

@ -0,0 +1,98 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { Library, LibraryType } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component';
@Component({
selector: 'app-manage-library',
templateUrl: './manage-library.component.html',
styleUrls: ['./manage-library.component.scss']
})
export class ManageLibraryComponent implements OnInit, OnDestroy {
libraries: Library[] = [];
createLibraryToggle = false;
loading = false;
/**
* If a deletion is in progress for a library
*/
deletionInProgress: boolean = false;
private readonly onDestroy = new Subject<void>();
constructor(private modalService: NgbModal, private libraryService: LibraryService, private toastr: ToastrService, private confirmService: ConfirmService) { }
ngOnInit(): void {
this.getLibraries();
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
getLibraries() {
this.loading = true;
this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => {
this.libraries = libraries;
this.loading = false;
});
}
editLibrary(library: Library) {
const modalRef = this.modalService.open(LibraryEditorModalComponent);
modalRef.componentInstance.library = library;
modalRef.closed.pipe(takeUntil(this.onDestroy)).subscribe(refresh => {
if (refresh) {
this.getLibraries();
}
});
}
addLibrary() {
const modalRef = this.modalService.open(LibraryEditorModalComponent);
modalRef.closed.pipe(takeUntil(this.onDestroy)).subscribe(refresh => {
if (refresh) {
this.getLibraries();
}
});
}
async deleteLibrary(library: Library) {
if (await this.confirmService.confirm('Are you sure you want to delete this library? You cannot undo this action.')) {
this.deletionInProgress = true;
this.libraryService.delete(library.id).pipe(take(1)).subscribe(() => {
this.deletionInProgress = false;
this.getLibraries();
this.toastr.success('Library has been removed'); // BUG: This is not causing a refresh
});
}
}
scanLibrary(library: Library) {
this.libraryService.scan(library.id).pipe(take(1)).subscribe(() => {
this.toastr.success('A scan has been queued for ' + library.name);
});
}
libraryType(libraryType: LibraryType) {
switch(libraryType) {
case LibraryType.Book:
return 'Book';
case LibraryType.Comic:
return 'Comic';
case LibraryType.Manga:
return 'Manga';
case LibraryType.MangaImages:
return 'Images (Manga)';
case LibraryType.ComicImages:
return 'Images (Comic)';
}
}
}

View file

@ -0,0 +1,64 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<p class="text-warning pt-2">Port and Logging Level require a manual restart of Kavita to take effect.</p>
<div class="form-group">
<label for="settings-cachedir">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
<span class="sr-only" id="settings-cachedir-help">Where the server place 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="form-group">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="stat-collection">Allow Anonymous Usage Collection</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="statTooltip" role="button" tabindex="0"></i>
<ng-template #statTooltip>Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes. Requires restart to take effect.</span>
<p class="accent">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes. Requires restart to take effect</p>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="allowStatCollection">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metatdata around manga files.</ng-template>
<span class="sr-only" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
<select class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-tasks-backup">Library Database Backup</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How often Kavita will backup the database.</ng-template>
<span class="sr-only" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
<select class="form-control" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="float-right">
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</div>

View file

@ -0,0 +1,11 @@
@import '../../../assets/themes/dark.scss';
.accent {
font-style: italic;
font-size: 0.7rem;
background-color: $dark-form-background;
padding: 10px;
color: lightgray;
border-radius: 6px;
box-shadow: inset 0px 0px 8px 1px $dark-form-background
}

View file

@ -0,0 +1,61 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
@Component({
selector: 'app-manage-settings',
templateUrl: './manage-settings.component.html',
styleUrls: ['./manage-settings.component.scss']
})
export class ManageSettingsComponent implements OnInit {
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
taskFrequencies: Array<string> = [];
logLevels: Array<string> = [];
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
ngOnInit(): void {
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
this.taskFrequencies = frequencies;
});
this.settingsService.getLoggingLevels().pipe(take(1)).subscribe(levels => {
this.logLevels = levels;
});
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required]));
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
});
}
resetForm() {
this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory);
this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan);
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
}
saveSettings() {
const modelSettings = this.settingsForm.value;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
}, (err: any) => {
console.error('error: ', err);
});
}
}

View file

@ -0,0 +1,43 @@
<div class="container-fluid">
<h3>About System</h3>
<hr/>
<div class="form-group" *ngIf="serverInfo">
<dl>
<dt>Version</dt>
<dd>{{serverInfo.kavitaVersion}}</dd>
<dt>.NET Version</dt>
<dd>{{serverInfo.dotNetVersion}}</dd>
</dl>
</div>
<h3>More Info</h3>
<hr/>
<div>
<div class="row">
<div class="col-4">Home page:</div>
<div class="col"><a href="https://kavitareader.com" target="_blank">kavitareader.com</a></div>
</div>
<div class="row">
<div class="col-4">Wiki:</div>
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank">wiki.kavitareader.com</a></div>
</div>
<div class="row">
<div class="col-4">Discord:</div>
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank">discord.gg/b52wT37kt7</a></div>
</div>
<div class="row">
<div class="col-4">Donations:</div>
<div class="col"><a href="https://opencollective.com/kavita" target="_blank">opencollective.com/kavita</a></div>
</div>
<div class="row">
<div class="col-4">Source:</div>
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank">github.com/Kareadita/Kavita</a></div>
</div>
<div class="row">
<div class="col-4">Feature Requests:</div>
<div class="col"><a href="https://feathub.com/Kareadita/Kavita" target="_blank">Feathub</a><br/>
<a href="https://github.com/Kareadita/Kavita/issues" target="_blank">Github issues</a></div>
</div>
</div>

View file

@ -0,0 +1,61 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
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';
@Component({
selector: 'app-manage-system',
templateUrl: './manage-system.component.html',
styleUrls: ['./manage-system.component.scss']
})
export class ManageSystemComponent implements OnInit {
settingsForm: FormGroup = new FormGroup({});
serverSettings!: ServerSettings;
serverInfo!: ServerInfo;
constructor(private settingsService: SettingsService, private toastr: ToastrService, private serverService: ServerService) { }
ngOnInit(): void {
this.serverService.getServerInfo().pipe(take(1)).subscribe(info => {
this.serverInfo = info;
});
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required]));
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
});
}
resetForm() {
this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory);
this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan);
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
}
saveSettings() {
const modelSettings = this.settingsForm.value;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
}, (err: any) => {
console.error('error: ', err);
});
}
}

View file

@ -0,0 +1,50 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Users</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="createMember()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add User</span></button></div>
</div>
<ul class="list-group" *ngIf="!createMemberToggle; else createUser">
<li *ngFor="let member of members" class="list-group-item">
<div>
<h4>
{{member.username | titlecase}} <span *ngIf="member.isAdmin" class="badge badge-pill badge-secondary">Admin</span>
<div class="float-right" *ngIf="canEditMember(member)">
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary" (click)="openEditLibraryAccess(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
</div>
</h4>
<div>Last Active:
<span *ngIf="member.lastActive == '0001-01-01T00:00:00'; else activeDate">Never</span>
<ng-template #activeDate>
{{member.lastActive | date: 'MM/dd/yyyy'}}
</ng-template>
</div>
<div *ngIf="!member.isAdmin">Sharing: {{formatLibraries(member)}}</div>
<div>
Roles: <span *ngIf="getRoles(member).length === 0; else showRoles">None</span>
<ng-template #showRoles>
<app-tag-badge *ngFor="let role of getRoles(member)">{{role}}</app-tag-badge>
</ng-template>
<button class="btn btn-icon" title="{{hasAdminRole(member) ? 'Admins have all feature permissions' : 'Edit Role'}}" (click)="openEditRole(member)" [disabled]="hasAdminRole(member)">
<i class="fa fa-pen" aria-hidden="true"></i>
<span class="sr-only">Edit Role</span>
</button>
</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item" *ngIf="members.length === 0 && !loadingMembers">
There are no other users.
</li>
</ul>
<ng-template #createUser>
<app-register-member (created)="onMemberCreated($event)"></app-register-member>
</ng-template>
</div>

View file

@ -0,0 +1,107 @@
import { Component, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators';
import { MemberService } from 'src/app/_services/member.service';
import { Member } from 'src/app/_models/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { LibraryAccessModalComponent } from '../_modals/library-access-modal/library-access-modal.component';
import { ToastrService } from 'ngx-toastr';
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component';
@Component({
selector: 'app-manage-users',
templateUrl: './manage-users.component.html',
styleUrls: ['./manage-users.component.scss']
})
export class ManageUsersComponent implements OnInit {
members: Member[] = [];
loggedInUsername = '';
// Create User functionality
createMemberToggle = false;
loadingMembers = false;
constructor(private memberService: MemberService,
private accountService: AccountService,
private modalService: NgbModal,
private toastr: ToastrService,
private confirmService: ConfirmService) {
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
this.loggedInUsername = user.username;
});
}
ngOnInit(): void {
this.loadMembers();
}
loadMembers() {
this.loadingMembers = true;
this.memberService.getMembers().subscribe(members => {
this.members = members.filter(member => member.username !== this.loggedInUsername);
this.loadingMembers = false;
});
}
canEditMember(member: Member): boolean {
return this.loggedInUsername !== member.username;
}
createMember() {
this.createMemberToggle = true;
}
onMemberCreated(success: boolean) {
this.createMemberToggle = false;
this.loadMembers();
}
openEditLibraryAccess(member: Member) {
const modalRef = this.modalService.open(LibraryAccessModalComponent);
modalRef.componentInstance.member = member;
modalRef.closed.subscribe(result => {
if (result) {
this.loadMembers();
}
});
}
async deleteUser(member: Member) {
if (await this.confirmService.confirm('Are you sure you want to delete this user?')) {
this.memberService.deleteMember(member.username).subscribe(() => {
this.loadMembers();
this.toastr.success(member.username + ' has been deleted.');
});
}
}
openEditRole(member: Member) {
const modalRef = this.modalService.open(EditRbsModalComponent);
modalRef.componentInstance.member = member;
}
updatePassword(member: Member) {
const modalRef = this.modalService.open(ResetPasswordModalComponent);
modalRef.componentInstance.member = member;
}
formatLibraries(member: Member) {
if (member.libraries.length === 0) {
return 'None';
}
return member.libraries.map(item => item.name).join(', ');
}
hasAdminRole(member: Member) {
return member.roles.indexOf('Admin') >= 0;
}
getRoles(member: Member) {
return member.roles.filter(item => item != 'Pleb');
}
}

View file

@ -0,0 +1,34 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { ServerSettings } from './_models/server-settings';
@Injectable({
providedIn: 'root'
})
export class SettingsService {
baseUrl = environment.apiUrl;
constructor(private http: HttpClient) { }
getServerSettings() {
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
}
updateServerSettings(model: ServerSettings) {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}
getTaskFrequencies() {
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
}
getLoggingLevels() {
return this.http.get<string[]>(this.baseUrl + 'settings/log-levels');
}
getLibraryTypes() {
return this.http.get<string[]>(this.baseUrl + 'settings/library-types');
}
}

View file

@ -0,0 +1,23 @@
<ng-container *ngIf="collectionTagId === 0; else collectionTagDetail;">
<app-card-detail-layout header="Collections"
[isLoading]="isLoading"
[items]="collections"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" [imageUrl]="item.coverImage" (clicked)="loadCollection(item)"></app-card-item>
</ng-template>
</app-card-detail-layout>
</ng-container>
<ng-template #collectionTagDetail>
<app-card-detail-layout header="{{collectionTagName}} Collection"
[isLoading]="isLoading"
[items]="series"
[pagination]="seriesPagination"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-template>

View file

@ -0,0 +1,108 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { CollectionTagService } from '../_services/collection-tag.service';
import { SeriesService } from '../_services/series.service';
/**
* This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc.
*/
@Component({
selector: 'app-all-collections',
templateUrl: './all-collections.component.html',
styleUrls: ['./all-collections.component.scss']
})
export class AllCollectionsComponent implements OnInit {
isLoading: boolean = true;
collections: CollectionTag[] = [];
collectionTagId: number = 0; // 0 is not a valid id, if 0, we will load all tags
collectionTagName: string = '';
series: Array<Series> = [];
seriesPagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = [];
constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, private modalService: NgbModal) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id');
if (routeId != null) {
this.collectionTagId = parseInt(routeId, 10);
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
const matchingTags = this.collections.filter(t => t.id === this.collectionTagId);
if (matchingTags.length === 0) {
this.toastr.error('You don\'t have access to any libraries this tag belongs to or this tag is invalid');
this.router.navigate(['collections']);
return;
}
this.collectionTagName = tags.filter(item => item.id === this.collectionTagId)[0].title;
});
}
}
ngOnInit() {
this.loadPage();
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
}
loadCollection(item: CollectionTag) {
this.collectionTagId = item.id;
this.collectionTagName = item.title;
this.router.navigate(['collections', this.collectionTagId]);
this.loadPage();
}
onPageChange(pagination: Pagination) {
this.router.navigate(['collections', this.collectionTagId], {replaceUrl: true, queryParamsHandling: 'merge', queryParams: {page: this.seriesPagination.currentPage} });
}
loadPage() {
// TODO: See if we can move this pagination code into layout code
const page = this.route.snapshot.queryParamMap.get('page');
if (page != null) {
if (this.seriesPagination === undefined || this.seriesPagination === null) {
this.seriesPagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
}
this.seriesPagination.currentPage = parseInt(page, 10);
}
// Reload page after a series is updated or first load
if (this.collectionTagId === 0) {
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
this.isLoading = false;
});
} else {
this.seriesService.getSeriesForTag(this.collectionTagId, this.seriesPagination?.currentPage, this.seriesPagination?.itemsPerPage).subscribe(tags => {
this.series = tags.result;
this.seriesPagination = tags.pagination;
this.isLoading = false;
window.scrollTo(0, 0);
});
}
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag;
modalRef.closed.subscribe((reloadNeeded: boolean) => {
if (reloadNeeded) {
this.loadPage();
}
});
break;
default:
break;
}
}
}

View file

@ -0,0 +1,61 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { HomeComponent } from './home/home.component';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { LibraryComponent } from './library/library.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { UserLoginComponent } from './user-login/user-login.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
const routes: Routes = [
{path: '', component: HomeComponent},
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
},
{path: 'library', component: LibraryComponent},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard, LibraryAccessGuard],
children: [
{path: 'library/:id', component: LibraryDetailComponent},
{path: 'library/:libraryId/series/:seriesId', component: SeriesDetailComponent},
{
path: 'library/:libraryId/series/:seriesId/manga',
loadChildren: () => import('../app/manga-reader/manga-reader.module').then(m => m.MangaReaderModule)
},
{
path: 'library/:libraryId/series/:seriesId/book',
loadChildren: () => import('../app/book-reader/book-reader.module').then(m => m.BookReaderModule)
}
]
},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
children: [
{path: 'recently-added', component: RecentlyAddedComponent},
{path: 'collections', component: AllCollectionsComponent},
{path: 'collections/:id', component: AllCollectionsComponent},
]
},
{path: 'login', component: UserLoginComponent},
{path: 'preferences', component: UserPreferencesComponent},
{path: 'no-connection', component: NotConnectedComponent},
{path: '**', component: HomeComponent, pathMatch: 'full'}
];
@NgModule({
imports: [RouterModule.forRoot(routes, {scrollPositionRestoration: 'enabled'})],
exports: [RouterModule]
})
export class AppRoutingModule { }

View file

@ -0,0 +1,5 @@
<app-nav-header></app-nav-header>
<div [ngStyle]="(navService?.navbarVisible$ | async) ? {'padding-top': 'calc(56px + 5px)', 'height': '100%'} : {}">
<a id="content"></a>
<router-outlet></router-outlet>
</div>

View file

View file

@ -0,0 +1,30 @@
import { Component, OnInit } from '@angular/core';
import { AccountService } from './_services/account.service';
import { NavService } from './_services/nav.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
constructor(private accountService: AccountService, public navService: NavService) { }
ngOnInit(): void {
this.setCurrentUser();
}
setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage();
this.accountService.setCurrentUser(user);
if (user) {
this.navService.setDarkMode(user.preferences.siteDarkMode);
} else {
this.navService.setDarkMode(true);
}
}
}

View file

@ -0,0 +1,142 @@
import { BrowserModule, Title } from '@angular/platform-browser';
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home/home.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
import { ToastrModule } from 'ngx-toastr';
import { ErrorInterceptor } from './_interceptors/error.interceptor';
import { LibraryComponent } from './library/library.component';
import { SharedModule } from './shared/shared.module';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { LazyLoadImageModule} from 'ng-lazyload-image';
import { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import * as Sentry from '@sentry/angular';
import { environment } from 'src/environments/environment';
import { version } from 'package.json';
import { Router } from '@angular/router';
import { RewriteFrames as RewriteFramesIntegration } from '@sentry/integrations';
import { Dedupe as DedupeIntegration } from '@sentry/integrations';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
let sentryProviders: any[] = [];
if (environment.production) {
Sentry.init({
dsn: 'https://db1a1f6445994b13a6f479512aecdd48@o641015.ingest.sentry.io/5757426',
environment: environment.production ? 'prod' : 'dev',
release: version,
integrations: [
new Sentry.Integrations.GlobalHandlers({
onunhandledrejection: true,
onerror: true
}),
new DedupeIntegration(),
new RewriteFramesIntegration(),
],
ignoreErrors: [new RegExp(/\/api\/admin/)],
tracesSampleRate: 0,
});
Sentry.configureScope(scope => {
scope.setUser({
username: 'Not authorized'
});
scope.setTag('production', environment.production);
scope.setTag('version', version);
});
sentryProviders = [{
provide: ErrorHandler,
useValue: Sentry.createErrorHandler({
showDialog: false,
}),
},
{
provide: Sentry.TraceService,
deps: [Router],
},
{
provide: APP_INITIALIZER,
useFactory: () => () => {},
deps: [Sentry.TraceService],
multi: true,
}];
}
@NgModule({
declarations: [
AppComponent,
HomeComponent,
NavHeaderComponent,
UserLoginComponent,
LibraryComponent,
LibraryDetailComponent,
SeriesDetailComponent,
NotConnectedComponent, // Move into ExtrasModule
UserPreferencesComponent, // Move into SettingsModule
EditSeriesModalComponent,
ReviewSeriesModalComponent,
PersonBadgeComponent,
AllCollectionsComponent,
EditCollectionTagsComponent,
RecentlyAddedComponent,
],
imports: [
HttpClientModule,
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
ReactiveFormsModule,
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
NgbTooltipModule, // Shared & SettingsModule
NgbRatingModule, // Series Detail
NgbCollapseModule, // Series Edit Modal
NgbNavModule, // Series Edit Modal
NgbAccordionModule, // User Preferences
NgxSliderModule, // User Preference
NgbPaginationModule,
LazyLoadImageModule,
SharedModule,
CarouselModule,
TypeaheadModule,
FormsModule, // EditCollection Modal
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},
//{ provide: LAZYLOAD_IMAGE_HOOKS, useClass: ScrollHooks } // Great, but causes flashing after modals close
Title,
...sentryProviders,
],
entryComponents: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View file

@ -0,0 +1,6 @@
export interface BookChapterItem {
title: string;
page: number;
part: string;
children: Array<BookChapterItem>;
}

View file

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookReaderComponent } from './book-reader/book-reader.component';
import { BookReaderRoutingModule } from './book-reader.router.module';
import { SharedModule } from '../shared/shared.module';
import { SafeStylePipe } from './safe-style.pipe';
import { ReactiveFormsModule } from '@angular/forms';
import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
@NgModule({
declarations: [BookReaderComponent, SafeStylePipe],
imports: [
CommonModule,
BookReaderRoutingModule,
ReactiveFormsModule,
SharedModule,
NgbProgressbarModule,
NgbTooltipModule
], exports: [
BookReaderComponent,
SafeStylePipe
]
})
export class BookReaderModule { }

View file

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { BookReaderComponent } from './book-reader/book-reader.component';
const routes: Routes = [
{
path: ':chapterId',
component: BookReaderComponent,
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class BookReaderRoutingModule { }

View file

@ -0,0 +1,134 @@
<div class="container-flex {{darkMode ? 'dark-mode' : ''}}">
<div class="fixed-top" #stickyTop>
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="backgroundColor" (drawerClosed)="closeDrawer()">
<div header>
<h2 style="margin-top: 0.5rem">Book Settings
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
<span aria-hidden="true">&times;</span>
</button>
</h2>
</div>
<div body class="drawer-body">
<div class="control-container">
<div class="controls">
<form [formGroup]="settingsForm">
<div class="form-group">
<label for="library-type">Font Family</label>
<select class="form-control" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontFamilies; let i = index">{{opt | titlecase}}</option>
</select>
</div>
</form>
</div>
<div class="controls">
<label id="fontsize">Font Size</label>
<button (click)="updateFontSize(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="fontsize"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['font-size']}}</span>
<button (click)="updateFontSize(10)" class="btn btn-icon" title="Increase" aria-labelledby="fontsize"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="linespacing">Line Spacing</label>
<button (click)="updateLineSpacing(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="linespacing"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['line-height']}}</span>
<button (click)="updateLineSpacing(10)" class="btn btn-icon" title="Increase" aria-labelledby="linespacing"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="margin">Margin</label>
<button (click)="updateMargin(-5)" class="btn btn-icon" title="Remove Margin" aria-labelledby="margin"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['margin-right']}}</span>
<button (click)="updateMargin(5)" class="btn btn-icon" title="Add Margin" aria-labelledby="margin"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="readingdirection">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}"><i class="fa {{readingDirection === 0 ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}</span></button>
</div>
<div class="controls">
<label id="darkmode">Dark Mode</label>
<button (click)="toggleDarkMode(false)" class="btn btn-icon" aria-labelledby="darkmode" title="Off"><i class="fa fa-sun" aria-hidden="true"></i></button>
<button (click)="toggleDarkMode(true)" class="btn btn-icon" aria-labelledby="darkmode" title="On"><i class="fa fa-moon" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="tap-pagination">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tap-pagination-help"></i></label>
<ng-template #tapPaginationTooltip>The ability to click the sides of the page to page left and right</ng-template>
<span class="sr-only" id="tap-pagination-help">The ability to click the sides of the page to page left and right</span>
<button (click)="toggleClickToPaginate()" class="btn btn-icon" aria-labelledby="tap-pagination"><i class="fa fa-arrows-alt-h {{clickToPaginate ? 'icon-primary-color' : ''}}" aria-hidden="true"></i><span *ngIf="darkMode">&nbsp;{{clickToPaginate ? 'On' : 'Off'}}</span></button>
</div>
<div class="row no-gutters justify-content-between">
<button (click)="resetSettings()" class="btn btn-secondary col">Reset</button>
<button (click)="saveSettings()" class="btn btn-primary col" style="margin-left:10px;">Save</button>
</div>
</div>
<div class="row no-gutters">
<div class="col-1">{{pageNum}}</div>
<div class="col-10" style="margin-top: 9px">
<ngb-progressbar style="cursor: pointer" title="Go to page" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
</div>
<div class="col-1">{{maxPages - 1}}</div>
</div>
<div class="table-of-contents">
<h3>Table of Contents</h3>
<div *ngIf="chapters.length === 0">
<em>This book does not have Table of Contents set in the metadata or a toc file</em>
</div>
<div *ngIf="chapters.length === 1; else nestedChildren">
<ul>
<li *ngFor="let chapter of chapters[0].children">
<a href="javascript:void(0);" (click)="loadChapter(chapter.page, chapter.part)">{{chapter.title}}</a>
</li>
</ul>
</div>
<ng-template #nestedChildren>
<ul *ngFor="let chapterGroup of chapters" style="padding-inline-start: 0px">
<li class="{{chapterGroup.page == pageNum ? 'active': ''}}" (click)="loadChapter(chapterGroup.page, '')">
{{chapterGroup.title}}
</li>
<ul *ngFor="let chapter of chapterGroup.children">
<li class="{{cleanIdSelector(chapter.part) === currentPageAnchor ? 'active' : ''}}">
<a href="javascript:void(0);" (click)="loadChapter(chapter.page, chapter.part)">{{chapter.title}}</a>
</li>
</ul>
</ul>
</ng-template>
</div>
</div>
</app-drawer>
</div>
<!-- This pushes down the page. Need to overlay
<ng-container *ngIf="isLoading">
<div class="d-flex justify-content-center m-5">
<div class="spinner-border text-secondary loading" role="status">
<span class="invisible">Loading...</span>
</div>
</div>
</ng-container> -->
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
<div #readingHtml [innerHtml]="page" *ngIf="page !== undefined"></div>
<div class="left {{clickOverlayClass('left')}}" (click)="prevPage()" *ngIf="clickToPaginate">
</div>
<div class="right {{clickOverlayClass('right')}}" (click)="nextPage()" *ngIf="clickToPaginate">
</div>
<div [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" *ngIf="page !== undefined && scrollbarNeeded">
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
</div>
</div>
<ng-template #actionBar>
<div class="reading-bar row no-gutters justify-content-between">
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()" [disabled]="readingDirection === 0 ? pageNum === 0 : pageNum + 1 >= maxPages - 1" title="{{readingDirection === 0 ? 'Previous' : 'Next'}} Page"><i class="fa fa-arrow-left" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{readingDirection === 0 ? 'Previous' : 'Next'}}</span></button>
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Go Back</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Settings</span></button>
<div class="book-title col-2 phone-hidden">{{bookTitle}} </div>
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Close</span></button>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" [disabled]="readingDirection === 0 ? pageNum + 1 >= maxPages - 1 : pageNum === 0" (click)="nextPage()" title="{{readingDirection === 0 ? 'Next' : 'Previous'}} Page"><span class="phone-hidden">{{readingDirection === 0 ? 'Next' : 'Previous'}}&nbsp;</span><i class="fa fa-arrow-right" aria-hidden="true"></i></button>
</div>
</ng-template>
</div>

View file

@ -0,0 +1,203 @@
@font-face {
font-family: "Fira_Sans";
src: url(../../../assets/fonts/Fira_Sans/FiraSans-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Lato";
src: url(../../../assets/fonts/Lato/Lato-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Libre_Baskerville";
src: url(../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Merriweather";
src: url(../../../assets/fonts/Merriweather/Merriweather-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Nanum_Gothic";
src: url(../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf) format("truetype");
}
@font-face {
font-family: "RocknRoll_One";
src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype");
}
$primary-color: #0062cc;
.control-container {
padding-bottom: 5px;
}
.table-of-contents li {
cursor: pointer;
&.active {
font-weight: bold;
}
}
.dark-mode {
color: #dcdcdc !important;
background-image: none !important;
background-color: #292929 !important;
*:not(code), *:not(a) {
background-color: #292929;
box-shadow: none;
text-shadow: none;
border-radius: unset;
color: #dcdcdc !important;
}
*:not(input), *:not(code), *:not(:link) {
color: #dcdcdc !important;
}
code {
color: #e83e8c !important;
}
.btn-icon {
background-color: transparent;
}
:link, a {
color: #8db2e5 !important;
}
// Coppied
// html, body {
// color: #dcdcdc !important;
// background-image: none !important;
// background-color: #292929 !important;
// }
// html::before, body::before {
// background-image: none !important;
// }
// html *:not(input) {color: #dcdcdc !important}
// html * {background-color: rgb(41, 41, 41, 0.90) !important}
// html *, html *[id], html *[class] {
// box-shadow: none !important;
// text-shadow: none !important;
// border-radius: unset !important;
// border-color: #555555 !important;
// outline-color: #555555 !important;
// }
img, img[src] {
z-index: 1;
filter: brightness(0.85) !important;
background-color: initial !important;
}
// video, video[src] {
// z-index: 1;
// background-color: transparent !important;
// }
// input:not([type='button']):not([type='submit']) {
// color: #dcdcdc !important;
// background-image: none !important;
// background-color: #333333 !important;
// }
// textarea, textarea[class], input[type='text'], input[type='text'][class] {
// color: #dcdcdc !important;
// background-color: #555555 !important;
// }
// svg:not([fill]) {fill: #7d7d7d !important}
// li, select {background-image: none !important}
// input[type='text'], input[type='search'] {text-indent: 10px}
// a {background-color: rgba(255, 255, 255, 0.01) !important}
// html cite, html cite *, html cite *[class] {color: #029833 !important}
// svg[fill], button, input[type='button'], input[type='submit'] {opacity: 0.85 !important}
// :before {color: #dcdcdc !important}
// :link:not(cite), :link *:not(cite) {color: #8db2e5 !important}
// :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important}
:visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important}
:link:not(cite), :link *:not(cite) {color: #8db2e5 !important}
}
.reading-bar {
background-color: white;
overflow: hidden;
}
@media(max-width: 875px) {
.book-title {
display: none;
}
}
.book-title {
margin-top: 10px;
text-align: center;
text-transform: capitalize;
}
.reading-section {
height: 100vh;
}
.drawer-body {
padding-bottom: 20px;
}
// Click to Paginate styles
.icon-primary-color {
color: $primary-color;
}
.dark-mode .overlay {
opacity: 0;
}
.right {
position: fixed;
right: 0px;
top: 0px;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
opacity: 0;
background: transparent;
}
.left {
position: fixed;
left: 0px;
top: 0px;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
opacity: 0;
background: transparent;
}
.highlight {
background-color: rgba(65, 225, 100, 0.5) !important;
animation: fadein .5s both;
}
.highlight-2 {
background-color: rgba(65, 105, 225, 0.5) !important;
animation: fadein .5s both;
}

View file

@ -0,0 +1,811 @@
import { AfterViewInit, Component, ElementRef, HostListener, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core';
import {Location} from '@angular/common';
import { FormControl, FormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, fromEvent, Subject } from 'rxjs';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { Chapter } from 'src/app/_models/chapter';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { BookService } from '../book.service';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { BookChapterItem } from '../_models/book-chapter-item';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Stack } from 'src/app/shared/data-structures/stack';
import { Preferences } from 'src/app/_models/preferences/preferences';
import { MemberService } from 'src/app/_services/member.service';
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
interface PageStyle {
'font-family': string;
'font-size': string;
'line-height': string;
'margin-left': string;
'margin-right': string;
}
interface HistoryPoint {
page: number;
scrollOffset: number;
}
const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up
const SCROLL_PART_TIMEOUT = 5000;
@Component({
selector: 'app-book-reader',
templateUrl: './book-reader.component.html',
styleUrls: ['./book-reader.component.scss'],
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'))
])
]
})
export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
libraryId!: number;
seriesId!: number;
volumeId!: number;
chapterId!: number;
chapter!: Chapter;
chapters: Array<BookChapterItem> = [];
pageNum = 0;
maxPages = 1;
adhocPageHistory: Stack<HistoryPoint> = new Stack<HistoryPoint>();
user!: User;
drawerOpen = false;
isLoading = true;
bookTitle: string = '';
settingsForm: FormGroup = new FormGroup({});
clickToPaginate = false;
clickToPaginateVisualOverlay = false;
clickToPaginateVisualOverlayTimeout: any = undefined; // For animation
clickToPaginateVisualOverlayTimeout2: any = undefined; // For kicking off animation, giving enough time to render html
page: SafeHtml | undefined = undefined; // This is the html we get from the server
styles: SafeHtml | undefined = undefined; // This is the css we get from the server
@ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef<HTMLDivElement>;
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
/**
* Internal property used to capture all the different css properties to render on all elements
*/
pageStyles!: PageStyle;
/**
* List of all font families user can select from
*/
fontFamilies: Array<string> = [];
darkMode = false;
backgroundColor: string = 'white';
readerStyles: string = '';
darkModeStyleElem!: HTMLElement;
topOffset: number = 0; // Offset for drawer and rendering canvas
scrollbarNeeded = false; // Used for showing/hiding bottom action bar
readingDirection: ReadingDirection = ReadingDirection.LeftToRight;
private readonly onDestroy = new Subject<void>();
pageAnchors: {[n: string]: number } = {};
currentPageAnchor: string = '';
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [1] });
/**
* Last seen bookmark part path
*/
lastSeenScrollPartPath: string = '';
// Temp hack: Override background color for reader and restore it onDestroy
originalBodyColor: string | undefined;
darkModeStyles = `
*:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) {
color: #dcdcdc !important;
}
code {
color: #e83e8c !important;
}
// .btn-icon {
// background-color: transparent;
// }
:link, a {
color: #8db2e5 !important;
}
img, img[src] {
z-index: 1;
filter: brightness(0.85) !important;
background-color: initial !important;
}
`;
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService) {
this.navService.hideNavBar();
this.darkModeStyleElem = this.renderer.createElement('style');
this.darkModeStyleElem.innerHTML = this.darkModeStyles;
this.fontFamilies = this.bookService.getFontFamilies();
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
if (this.user.preferences.bookReaderFontFamily === undefined) {
this.user.preferences.bookReaderFontFamily = 'default';
}
if (this.user.preferences.bookReaderFontSize === undefined) {
this.user.preferences.bookReaderFontSize = 100;
}
if (this.user.preferences.bookReaderLineSpacing === undefined) {
this.user.preferences.bookReaderLineSpacing = 100;
}
if (this.user.preferences.bookReaderMargin === undefined) {
this.user.preferences.bookReaderMargin = 0;
}
if (this.user.preferences.bookReaderReadingDirection === undefined) {
this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight;
}
this.readingDirection = this.user.preferences.bookReaderReadingDirection;
this.clickToPaginate = this.user.preferences.bookReaderTapToPaginate;
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(user.preferences.bookReaderFontFamily, []));
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => {
this.updateFontFamily(changes);
});
}
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
this.originalBodyColor = bodyNode.style.background;
}
this.resetSettings();
});
}
ngAfterViewInit() {
// check scroll offset and if offset is after any of the "id" markers, bookmark it
fromEvent(window, 'scroll')
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
if (this.isLoading) return;
if (Object.keys(this.pageAnchors).length === 0) return;
// get the height of the document so we can capture markers that are halfway on the document viewport
const verticalOffset = (window.pageYOffset
|| document.documentElement.scrollTop
|| document.body.scrollTop || 0) + (document.body.offsetHeight / 2);
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
if (alreadyReached.length > 0) {
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
} else {
this.currentPageAnchor = '';
}
if (this.lastSeenScrollPartPath !== '') {
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
}
});
}
ngOnDestroy(): void {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
bodyNode.style.background = this.originalBodyColor;
if (this.user.preferences.siteDarkMode) {
bodyNode.classList.add('bg-dark');
}
}
this.navService.showNavBar();
const head = document.querySelector('head');
this.renderer.removeChild(head, this.darkModeStyleElem);
if (this.clickToPaginateVisualOverlayTimeout !== undefined) {
clearTimeout(this.clickToPaginateVisualOverlayTimeout);
this.clickToPaginateVisualOverlayTimeout = undefined;
}
if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) {
clearTimeout(this.clickToPaginateVisualOverlayTimeout2);
this.clickToPaginateVisualOverlayTimeout2 = undefined;
}
this.onDestroy.next();
this.onDestroy.complete();
this.intersectionObserver.disconnect();
}
ngOnInit(): void {
const libraryId = this.route.snapshot.paramMap.get('libraryId');
const seriesId = this.route.snapshot.paramMap.get('seriesId');
const chapterId = this.route.snapshot.paramMap.get('chapterId');
if (libraryId === null || seriesId === null || chapterId === null) {
this.router.navigateByUrl('/home');
return;
}
this.libraryId = parseInt(libraryId, 10);
this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10);
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
if (!hasProgress) {
this.toggleDrawer();
this.toastr.info('You can modify book settings, save those settings for all books, and view table of contents from the drawer.');
}
});
forkJoin({
chapter: this.seriesService.getChapter(this.chapterId),
bookmark: this.readerService.getBookmark(this.chapterId),
chapters: this.bookService.getBookChapters(this.chapterId),
info: this.bookService.getBookInfo(this.chapterId)
}).pipe(take(1)).subscribe(results => {
this.chapter = results.chapter;
this.volumeId = results.chapter.volumeId;
this.maxPages = results.chapter.pages;
this.chapters = results.chapters;
this.pageNum = results.bookmark.pageNum;
this.bookTitle = results.info;
if (this.pageNum >= this.maxPages) {
this.pageNum = this.maxPages - 1;
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
// Check if user bookmark has part, if so load it so we scroll to it
this.loadPage(results.bookmark.bookScrollId || undefined);
}, () => {
setTimeout(() => {
this.closeReader();
}, 200);
});
}
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (event.key === KEY_CODES.RIGHT_ARROW) {
this.nextPage();
} else if (event.key === KEY_CODES.LEFT_ARROW) {
this.prevPage();
} else if (event.key === KEY_CODES.ESC_KEY) {
this.closeReader();
} else if (event.key === KEY_CODES.SPACE) {
this.toggleDrawer();
event.stopPropagation();
event.preventDefault();
} else if (event.key === KEY_CODES.G) {
this.goToPage();
}
}
handleIntersection(entries: IntersectionObserverEntry[]) {
const intersectingEntries = Array.from(entries).filter(entry => entry.isIntersecting).map(entry => entry.target);
intersectingEntries.sort((a: Element, b: Element) => {
const aTop = a.getBoundingClientRect().top;
const bTop = b.getBoundingClientRect().top;
if (aTop < bTop) {
return -1;
}
if (aTop > bTop) {
return 1;
}
return 0;
});
if (intersectingEntries.length > 0) {
let path = this.getXPathTo(intersectingEntries[0]);
if (path === '') { return; }
if (!path.startsWith('id')) {
path = '//html[1]/' + path;
}
this.lastSeenScrollPartPath = path;
}
}
loadChapter(pageNum: number, part: string) {
this.setPageNum(pageNum);
this.loadPage('id("' + part + '")');
}
closeReader() {
this.location.back();
}
resetSettings() {
const windowWidth = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;
let margin = '15%';
if (windowWidth <= 700) {
margin = '0%';
}
if (this.user) {
if (windowWidth > 700) {
margin = this.user.preferences.bookReaderMargin + '%';
}
this.pageStyles = {'font-family': this.user.preferences.bookReaderFontFamily, 'font-size': this.user.preferences.bookReaderFontSize + '%', 'margin-left': margin, 'margin-right': margin, 'line-height': this.user.preferences.bookReaderLineSpacing + '%'};
if (this.user.preferences.siteDarkMode && !this.user.preferences.bookReaderDarkMode) {
this.user.preferences.bookReaderDarkMode = true;
}
this.toggleDarkMode(this.user.preferences.bookReaderDarkMode);
} else {
this.pageStyles = {'font-family': 'default', 'font-size': '100%', 'margin-left': margin, 'margin-right': margin, 'line-height': '100%'};
this.toggleDarkMode(false);
}
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily);
this.updateReaderStyles();
}
/**
* Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value
* from 'kavita-part', which will cause the reader to scroll to the marker.
*/
addLinkClickHandlers() {
var links = this.readingSectionElemRef.nativeElement.querySelectorAll('a');
links.forEach((link: any) => {
link.addEventListener('click', (e: any) => {
if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; }
var page = parseInt(e.target.attributes['kavita-page'].value, 10);
if (this.adhocPageHistory.peek()?.page !== this.pageNum) {
this.adhocPageHistory.push({page: this.pageNum, scrollOffset: window.pageYOffset});
}
var partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined;
if (partValue && page === this.pageNum) {
this.scrollTo(e.target.attributes['kavita-part'].value);
return;
}
this.setPageNum(page);
this.loadPage(partValue);
});
});
}
moveFocus() {
const elems = document.getElementsByClassName('reading-section');
if (elems.length > 0) {
(elems[0] as HTMLDivElement).focus();
}
}
promptForPage() {
const question = 'There are ' + (this.maxPages - 1) + ' pages. What page do you want to go to?';
const goToPageNum = window.prompt(question, '');
if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; }
return goToPageNum;
}
goToPage(pageNum?: number) {
let page = pageNum;
if (pageNum === null || pageNum === undefined) {
const goToPageNum = this.promptForPage();
if (goToPageNum === null) { return; }
page = parseInt(goToPageNum.trim(), 10);
}
if (page === undefined || this.pageNum === page) { return; }
if (page > this.maxPages) {
page = this.maxPages;
} else if (page < 0) {
page = 0;
}
if (!(page === 0 || page === this.maxPages - 1)) {
page -= 1;
}
this.pageNum = page;
this.loadPage();
}
cleanIdSelector(id: string) {
const tokens = id.split('/');
if (tokens.length > 0) {
return tokens[0];
}
return id;
}
getPageMarkers(ids: Array<string>) {
try {
return document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', '));
} catch (Exception) {
// Fallback to anchors instead. Some books have ids that are not valid for querySelectors, so anchors should be used instead
return document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', '));
}
}
setupPageAnchors() {
this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => {
this.intersectionObserver.observe(elem);
});
this.pageAnchors = {};
this.currentPageAnchor = '';
const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0);
if (ids.length > 0) {
const elems = this.getPageMarkers(ids);
elems.forEach(elem => {
this.pageAnchors[elem.id] = elem.getBoundingClientRect().top;
});
}
}
loadPage(part?: string | undefined, scrollTop?: number | undefined) {
this.isLoading = true;
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => {
this.page = this.domSanitizer.bypassSecurityTrustHtml(content);
setTimeout(() => {
this.addLinkClickHandlers();
this.updateReaderStyles();
this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight;
const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img');
if (imgs === null || imgs.length === 0) {
this.setupPage(part, scrollTop);
return;
}
Promise.all(Array.from(imgs)
.filter(img => !img.complete)
.map(img => new Promise(resolve => { img.onload = img.onerror = resolve; })))
.then(() => {
this.setupPage(part, scrollTop);
});
}, 10);
});
}
setupPage(part?: string | undefined, scrollTop?: number | undefined) {
this.isLoading = false;
this.scrollbarNeeded = this.readingSectionElemRef.nativeElement.scrollHeight > this.readingSectionElemRef.nativeElement.clientHeight;
// Find all the part ids and their top offset
this.setupPageAnchors();
if (part !== undefined && part !== '') {
this.scrollTo(part);
} else if (scrollTop !== undefined && scrollTop !== 0) {
window.scroll({
top: scrollTop,
behavior: 'smooth'
});
} else {
window.scroll({
top: 0,
behavior: 'smooth'
});
}
}
setPageNum(pageNum: number) {
if (pageNum < 0) {
this.pageNum = 0;
} else if (pageNum >= this.maxPages - 1) {
this.pageNum = this.maxPages - 1;
} else {
this.pageNum = pageNum;
}
}
goBack() {
if (!this.adhocPageHistory.isEmpty()) {
const page = this.adhocPageHistory.pop();
if (page !== undefined) {
this.setPageNum(page.page);
this.loadPage(undefined, page.scrollOffset);
}
}
}
clickOverlayClass(side: 'right' | 'left') {
if (!this.clickToPaginateVisualOverlay) {
return '';
}
if (this.readingDirection === ReadingDirection.LeftToRight) {
return side === 'right' ? 'highlight' : 'highlight-2';
}
return side === 'right' ? 'highlight-2' : 'highlight';
}
prevPage() {
const oldPageNum = this.pageNum;
if (this.readingDirection === ReadingDirection.LeftToRight) {
this.setPageNum(this.pageNum - 1);
} else {
this.setPageNum(this.pageNum + 1);
}
if (oldPageNum === this.pageNum) { return; }
this.loadPage();
}
nextPage(event?: any) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const oldPageNum = this.pageNum;
if (this.readingDirection === ReadingDirection.LeftToRight) {
this.setPageNum(this.pageNum + 1);
} else {
this.setPageNum(this.pageNum - 1);
}
if (oldPageNum === this.pageNum) { return; }
this.loadPage();
}
updateFontSize(amount: number) {
let val = parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10);
if (val + amount > 300 || val + amount < 50) {
return;
}
this.pageStyles['font-size'] = val + amount + '%';
this.updateReaderStyles();
}
updateFontFamily(familyName: string) {
if (familyName === null) familyName = '';
let cleanedName = familyName.replace(' ', '_').replace('!important', '').trim();
if (cleanedName === 'default') {
this.pageStyles['font-family'] = 'inherit';
} else {
this.pageStyles['font-family'] = "'" + cleanedName + "'";
}
this.updateReaderStyles();
}
updateMargin(amount: number) {
let cleanedValue = this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim();
let val = parseInt(cleanedValue, 10);
if (val + amount > 30 || val + amount < 0) {
return;
}
this.pageStyles['margin-left'] = (val + amount) + '%';
this.pageStyles['margin-right'] = (val + amount) + '%';
this.updateReaderStyles();
}
updateLineSpacing(amount: number) {
const cleanedValue = parseInt(this.pageStyles['line-height'].replace('%', '').replace('!important', '').trim(), 10);
if (cleanedValue + amount > 250 || cleanedValue + amount < 100) {
return;
}
this.pageStyles['line-height'] = (cleanedValue + amount) + '%';
this.updateReaderStyles();
}
updateReaderStyles() {
if (this.readingHtml != undefined && this.readingHtml.nativeElement) {
for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) {
const elem = this.readingHtml.nativeElement.children.item(i);
if (elem?.tagName != 'STYLE') {
Object.entries(this.pageStyles).forEach(item => {
this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important);
});
}
}
}
}
toggleDarkMode(force?: boolean) {
if (force !== undefined) {
this.darkMode = force;
} else {
this.darkMode = !this.darkMode;
}
this.setOverrideStyles();
}
toggleReadingDirection() {
if (this.readingDirection === ReadingDirection.LeftToRight) {
this.readingDirection = ReadingDirection.RightToLeft;
} else {
this.readingDirection = ReadingDirection.LeftToRight;
}
}
getDarkModeBackgroundColor() {
return this.darkMode ? '#292929' : '#fff';
}
setOverrideStyles() {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
if (this.user.preferences.siteDarkMode) {
bodyNode.classList.remove('bg-dark');
}
bodyNode.style.background = this.getDarkModeBackgroundColor();
}
this.backgroundColor = this.getDarkModeBackgroundColor();
const head = document.querySelector('head');
if (this.darkMode) {
this.renderer.appendChild(head, this.darkModeStyleElem)
} else {
this.renderer.removeChild(head, this.darkModeStyleElem);
}
}
saveSettings() {
if (this.user === undefined) return;
const modelSettings = this.settingsForm.value;
const data: Preferences = {
readingDirection: this.user.preferences.readingDirection,
scalingOption: this.user.preferences.scalingOption,
pageSplitOption: this.user.preferences.pageSplitOption,
autoCloseMenu: this.user.preferences.autoCloseMenu,
readerMode: this.user.preferences.readerMode,
bookReaderDarkMode: this.darkMode,
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
bookReaderFontSize: parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10),
bookReaderLineSpacing: parseInt(this.pageStyles['line-height'].replace('!important', '').trim(), 10),
bookReaderMargin: parseInt(this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim(), 10),
bookReaderTapToPaginate: this.clickToPaginate,
bookReaderReadingDirection: this.readingDirection,
siteDarkMode: this.user.preferences.siteDarkMode,
};
this.accountService.updatePreferences(data).pipe(take(1)).subscribe((updatedPrefs) => {
this.toastr.success('User settings updated');
if (this.user) {
this.user.preferences = updatedPrefs;
}
this.resetSettings();
});
}
toggleDrawer() {
this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight;
this.drawerOpen = !this.drawerOpen;
}
closeDrawer() {
this.drawerOpen = false;
}
handleReaderClick(event: MouseEvent) {
if (this.drawerOpen) {
this.closeDrawer();
event.stopPropagation();
event.preventDefault();
}
}
scrollTo(partSelector: string) {
if (partSelector.startsWith('#')) {
partSelector = partSelector.substr(1, partSelector.length);
}
let element = null;
if (partSelector.startsWith('//') || partSelector.startsWith('id(')) {
// Part selector is a XPATH
element = this.getElementFromXPath(partSelector);
} else {
element = document.querySelector('*[id="' + partSelector + '"]');
}
if (element === null) return;
window.scroll({
top: element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET,
behavior: 'smooth'
});
}
toggleClickToPaginate() {
this.clickToPaginate = !this.clickToPaginate;
if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) {
clearTimeout(this.clickToPaginateVisualOverlayTimeout2);
this.clickToPaginateVisualOverlayTimeout2 = undefined;
}
if (!this.clickToPaginate) { return; }
this.clickToPaginateVisualOverlayTimeout2 = setTimeout(() => {
this.showClickToPaginateVisualOverlay();
}, 200);
}
showClickToPaginateVisualOverlay() {
this.clickToPaginateVisualOverlay = true;
if (this.clickToPaginateVisualOverlay && this.clickToPaginateVisualOverlayTimeout !== undefined) {
clearTimeout(this.clickToPaginateVisualOverlayTimeout);
this.clickToPaginateVisualOverlayTimeout = undefined;
}
this.clickToPaginateVisualOverlayTimeout = setTimeout(() => {
this.clickToPaginateVisualOverlay = false;
}, 1000);
}
getElementFromXPath(path: string) {
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node?.nodeType === Node.ELEMENT_NODE) {
return node as Element;
}
return null;
}
getXPathTo(element: any): string {
if (element === null) return '';
if (element.id !== '') { return 'id("' + element.id + '")'; }
if (element === document.body) { return element.tagName; }
let ix = 0;
const siblings = element.parentNode?.childNodes || [];
for (let sibling of siblings) {
if (sibling === element) {
return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
}
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
ix++;
}
}
return '';
}
}

View file

@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { BookChapterItem } from './_models/book-chapter-item';
export interface BookPage {
bookTitle: string;
styles: string;
html: string;
}
@Injectable({
providedIn: 'root'
})
export class BookService {
baseUrl = environment.apiUrl;
constructor(private http: HttpClient) { }
getFontFamilies() {
return ['default', 'EBGaramond', 'Fira Sans', 'Lato', 'Libre Baskerville', 'Merriweather', 'Nanum Gothic', 'RocknRoll One'];
}
getBookChapters(chapterId: number) {
return this.http.get<Array<BookChapterItem>>(this.baseUrl + 'book/' + chapterId + '/chapters');
}
getBookPage(chapterId: number, page: number) {
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, {responseType: 'text' as 'json'});
}
getBookInfo(chapterId: number) {
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-info', {responseType: 'text' as 'json'});
}
getBookPageUrl(chapterId: number, page: number) {
return this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page;
}
}

View file

@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({
name: 'safeStyle'
})
export class SafeStylePipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(value: string): unknown {
return this.sanitizer.bypassSecurityTrustStyle(value);
}
}

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