v0.5.0 Release (#960)
* Bump versions by dotnet-bump-version. * Color Theme applies to scrollbars (#793) * Moved more scss to use syntax to reduce css size * Hooked in color-scheme to help brower render scroll bars appropriately depending on color scheme user selects * Bump versions by dotnet-bump-version. * UI Updates + New Events (#806) * Implemented ability to see downloads users are performing on the events widget. * Fixed a bug where version update task was calling wrong code * Fixed a bug where when checking for updates, the event wouldn't be pushed to server with correct name. Added update check to the event widget rather than opening a modal on the user. * Relaxed password requirements to only be 6-32 characters and inform user on register form about the requirements * Removed a ton of duplicate logic for series cards where the logic was already defined in action service * Fixed OPDS total items giving a rounded number rather than total items. * Fixed off by one issue on OPDS pagination * Bump versions by dotnet-bump-version. * Update GA .net version (#818) * Local Metadata Integration Part 1 (#817) * Started with some basic plumbing with comic info parsing updating Series/Volume. * We can now get chapter title from comicInfo.xml * Hooked in the ability to store people into the chapter metadata. * Removed no longer used imports, fixed up some foreign key constraints on deleting series with person linked. * Refactored Summary out of the UI for Series into SeriesMetadata. Updated application to .net 6. There is a bug in metadata code for updating. * Removed the parallel.ForEach with a normal foreach which lets us use async. For I/O heavy code, shouldn't change much. * Refactored scan code to only check extensions with comic info, fixed a bug on scan events not using correct method name, removed summary field (still buggy) * Fixed a bug where on cancelling a metadata request in modal, underlying button would get stuck in a disabled state. * Changed how metadata selects the first volume to read summary info from. It will now select the first non-special volume rather than Volume 1. * More debugging and found more bugs to fix * Redid all the migrations as one single one. Fixed a bug with GetChapterInfo returning null when ChapterMetadata didn't exist for that Chapter. Fixed an issue with mapper failing on GetChapterMetadata. Started work on adding people and a design for people. * Fixed a bug where checking if file modified now takes into account if file has been processed at least once. Introduced a bug in saving people to series. * Just made code compilable again * Fixed up code. Now people for series and chapters add correctly without any db issues. * Things are working, but I'm not happy with how the management of Person is. I need to take into account that 1 person needs to map to an image and role is arbitrary. * Started adding UI code to showcase chapter metadata * Updated workflow to be .NET 6 * WIP of updating card detail to show the information more clearly and without so many if statements * Removed ChatperMetadata and store on the Chapter itself. Much easier to use and less joins. * Implemented Genre on SeriesMetadata level * Genres and People are now removed from Series level if they are no longer on comicInfo * PeopleHelper is done with unit tests. Everything is working. * Unit tests in place for Genre Helper * Starting on CacheHelper * Finished tests for ShouldUpdateCoverImage. Fixed and added tests in ArchiveService/ScannerService. * CacheHelper is fully tested * Some DI cleanup * Scanner Service now calls GetComicInfo for books. Added ability to update Series Sort name from metadata files (mainly epub as comicinfo doesn't have a field) * Forgot to move a line of code * SortName now populates from metadata (epub only, ComicInfo has no tags) * Cards now show the chapter title name if it's set on hover, else will default back to title. * Fixed a major issue with how MangaFiles were being updated with LastModified, which messed up our logic for avoiding refreshes. * Woohoo, more tests and some refactors to be able to test more services wtih mock filesystem. Fixed an issue where SortName was getting set as first chapter, but the Series was in a group. * Refactored the MangaFile creation code into the DbFactory where we also setup the first LastModified update. * Has file changed bug is now finally fixed * Remove dead genres, refactor genre to use title instead of name. * Refactored out a directory from ShouldUpdateCoverImage() to keep the code clean * Unit tests for ComicInfo on BookService. * Refactored series detail into it's own component * Series-detail now received refresh metadata events to refresh what's on screen * Removed references to Artist on PersonRole as it has no metadata mapping * Security audit * Fixed a benchmark * Updated JWT Token generator to use new methods in .NET 6 * Updated all the docker and build commands to use net6.0 * Commented out sonar scan since it's not setup for net6.0 yet. * Don't rely on test (#819) * Bump versions by dotnet-bump-version. * Update monorepo-build.sh Updated build to use .net6.0 * Bump versions by dotnet-bump-version. * Update sonar-scan.yml More updates to 6.0 * Update sonar-scan.yml Please work * Bump versions by dotnet-bump-version. * Please work * Bump versions by dotnet-bump-version. * Local Metadata Integration Part 1 (#820) * Started with some basic plumbing with comic info parsing updating Series/Volume. * We can now get chapter title from comicInfo.xml * Hooked in the ability to store people into the chapter metadata. * Removed no longer used imports, fixed up some foreign key constraints on deleting series with person linked. * Refactored Summary out of the UI for Series into SeriesMetadata. Updated application to .net 6. There is a bug in metadata code for updating. * Removed the parallel.ForEach with a normal foreach which lets us use async. For I/O heavy code, shouldn't change much. * Refactored scan code to only check extensions with comic info, fixed a bug on scan events not using correct method name, removed summary field (still buggy) * Fixed a bug where on cancelling a metadata request in modal, underlying button would get stuck in a disabled state. * Changed how metadata selects the first volume to read summary info from. It will now select the first non-special volume rather than Volume 1. * More debugging and found more bugs to fix * Redid all the migrations as one single one. Fixed a bug with GetChapterInfo returning null when ChapterMetadata didn't exist for that Chapter. Fixed an issue with mapper failing on GetChapterMetadata. Started work on adding people and a design for people. * Fixed a bug where checking if file modified now takes into account if file has been processed at least once. Introduced a bug in saving people to series. * Just made code compilable again * Fixed up code. Now people for series and chapters add correctly without any db issues. * Things are working, but I'm not happy with how the management of Person is. I need to take into account that 1 person needs to map to an image and role is arbitrary. * Started adding UI code to showcase chapter metadata * Updated workflow to be .NET 6 * WIP of updating card detail to show the information more clearly and without so many if statements * Removed ChatperMetadata and store on the Chapter itself. Much easier to use and less joins. * Implemented Genre on SeriesMetadata level * Genres and People are now removed from Series level if they are no longer on comicInfo * PeopleHelper is done with unit tests. Everything is working. * Unit tests in place for Genre Helper * Starting on CacheHelper * Finished tests for ShouldUpdateCoverImage. Fixed and added tests in ArchiveService/ScannerService. * CacheHelper is fully tested * Some DI cleanup * Scanner Service now calls GetComicInfo for books. Added ability to update Series Sort name from metadata files (mainly epub as comicinfo doesn't have a field) * Forgot to move a line of code * SortName now populates from metadata (epub only, ComicInfo has no tags) * Cards now show the chapter title name if it's set on hover, else will default back to title. * Fixed a major issue with how MangaFiles were being updated with LastModified, which messed up our logic for avoiding refreshes. * Woohoo, more tests and some refactors to be able to test more services wtih mock filesystem. Fixed an issue where SortName was getting set as first chapter, but the Series was in a group. * Refactored the MangaFile creation code into the DbFactory where we also setup the first LastModified update. * Has file changed bug is now finally fixed * Remove dead genres, refactor genre to use title instead of name. * Refactored out a directory from ShouldUpdateCoverImage() to keep the code clean * Unit tests for ComicInfo on BookService. * Refactored series detail into it's own component * Series-detail now received refresh metadata events to refresh what's on screen * Removed references to Artist on PersonRole as it has no metadata mapping * Security audit * Fixed a benchmark * Updated JWT Token generator to use new methods in .NET 6 * Updated all the docker and build commands to use net6.0 * Commented out sonar scan since it's not setup for net6.0 yet. * Removed some directives * Removed my test db * Bump versions by dotnet-bump-version. * .NET 6 Coding Patterns + Unit Tests (#823) * Refactored all files to have Interfaces within the same file. Started moving over to file-scoped namespaces. * Refactored common methods for getting underlying file's cover, pages, and extracting into 1 interface. * More refactoring around removing dependence on explicit filetype testing for getting information. * Code is buildable, tests are broken. Huge refactor (not completed) which makes most of DirectoryService testable with a mock filesystem (and thus the services that utilize it). * Finished porting DirectoryService to use mocked filesystem implementation. * Added a null check * Added a null check * Finished all unit tests for DirectoryService. * Some misc cleanup on the code * Fixed up some bugs from refactoring scan loop. * Implemented CleanupService testing and refactored more of DirectoryService to be non-static. Fixed a bug where cover file cleanup wasn't properly finding files due to a regex bug. * Fixed an issue in CleanupBackup() where we weren't properly selecting database files older than 30 days. Finished CleanupService Tests. * Refactored Flatten and RemoveNonImages to directory service to allow CacheService to be testable. * Finally have CacheService tested. Rewrote GetCachedPagePath() to be much more straightforward & performant. * Updated DefaultParserTests.cs to contain all existing tests and follow new test layout format. * All tests fixed up * Bump versions by dotnet-bump-version. * Bump docnet version to support x64 ARM pdfium binaries. (#826) * Bump versions by dotnet-bump-version. * Fixed bad build (#828) * Bump versions by dotnet-bump-version. * Bugfix/bad build (#829) * Fixed bad build * More directory service issues in Program * Bump versions by dotnet-bump-version. * Feature/local metadata more tags (#832) * Stashing code * removed some debug code on series detail page. Now detail is collapsed by default. * Added AgeRating * Fixed a crash when NetVips tries to write a cover file and cover directory is not existing. * When a card is selected for bulk actions, show an outline in addition to select box * Added AgeRating into the metadata parsing. Added a hack where ComicInfo uses Number in ComicInfo rather than Volume. This is to test out the effects on users libraries. * Added AgeRating and ReleaseDate to the metadata implelentation. * Bump versions by dotnet-bump-version. * Misc Fixes (#839) * Fixed a case where chapter was being parsed incorrectly when the series title ends in a number. * Updated Kavita to support Tome/T notation found in French comics * Added support for identifying European specials and expanded support for cleaning some tags used in European comics. During cleaning, if series starts with - or comma, remove it. * Fixed an issue where add to collection for a single series wasn't calling the bulk action handler * Fixed a NPE on AgeRating conversion. Fixed a bug where when looking for cover image, file extensions was throwing off sort code. * Refactored Natural Sort ordering to better follow how Windows behaves. This is a departure from how the original code executes. * GetCachedPagePath now uses natural sorting to pick the images for reading in a more correct order. * Updated parser to handle a case where there was more than one space as a separator * Bump versions by dotnet-bump-version. * Beefed up ToC generation to handle new ways of packing epub. (#840) * Bump versions by dotnet-bump-version. * Tachiyomi Enhancements (#845) * Added a new endpoint to get all Series with Progress info. * Fixed up some potential NPEs during scan * Commented out filter code, not ready for it. * Fixed up a parsing case for european comics * Refactored FilterDto to allow for specifying multiple formats to return. * Refactored FilterDto to allow for specifying multiple formats to return. * Refactored the UI to show OPDS as 3rd Party Clients since Tachiyomi now uses OPDS url scheme for authentication. * Bump versions by dotnet-bump-version. * In-Depth Filtering (#850) * Laying the foundation for the filter rework * Filtering by Genre is now possible. * Cleaned up code and preparing for People filtering * People filtering is hooked up for the frontend * Filtering now works. On Deck does not work with filtering currently due to a unique implementation. * More cleanup * Implemented the ability to reset the filters * Added a mobile drawer for filtering * Added some additional cases for NaturalSortComparer. Filter now uses a drawer on smaller screens. * Fixed a bug where backup service was not pointing to the correct directory. * Undid the fix, it's working as expected * Bump versions by dotnet-bump-version. * More Filtering and Support for ComicInfo v2.1 (draft) Tags (#851) * Added a reoccuring task to cleanup db entries that might be abandoned. On library page, the Library in question will be prepoulated. * Laid out the foundation for customized sorting. Added all series page to the UI when clicking on Libraries section header on home page so user can apply any filtering they like. * When filtering, the current library filter will now automatically filter out the options for people and genres. * Implemented Sorting controls * Clear now clears sorting and read progress. Sorting is disabled on deck and recently added. * Fixed an issue where all-series page couldn't click to open series * Don't let the user unselect the last read progress. Added new comicinfo v2.1 draft tags. * Hooked in Translator tag into backend and UI. * Fixed an issue where you could open multiple typeaheads at the same time * Integrated Translator and Tags ComicInfo extension fields. Started work on a badge expander. * Reworked a bit more on badge expander. Added the UI code for Age Rating and Tags * Integrated backend for Tags, Translator, and Age Rating * Metadata tags now collapse if more than 4 present * Some code cleanup * Made the not read badge slightly smaller * Bump versions by dotnet-bump-version. * More Filtering and Support for ComicInfo v2.1 (draft) Tags (#853) * Added a reoccuring task to cleanup db entries that might be abandoned. On library page, the Library in question will be prepoulated. * Laid out the foundation for customized sorting. Added all series page to the UI when clicking on Libraries section header on home page so user can apply any filtering they like. * When filtering, the current library filter will now automatically filter out the options for people and genres. * Implemented Sorting controls * Clear now clears sorting and read progress. Sorting is disabled on deck and recently added. * Fixed an issue where all-series page couldn't click to open series * Don't let the user unselect the last read progress. Added new comicinfo v2.1 draft tags. * Hooked in Translator tag into backend and UI. * Fixed an issue where you could open multiple typeaheads at the same time * Integrated Translator and Tags ComicInfo extension fields. Started work on a badge expander. * Reworked a bit more on badge expander. Added the UI code for Age Rating and Tags * Integrated backend for Tags, Translator, and Age Rating * Metadata tags now collapse if more than 4 present * Some code cleanup * Made the not read badge slightly smaller * Implemented Language filter. Fixed up some logic around Release Year not being set when month is missing. * Added a missing file and Updated Filter to use different design for layout * Fixed up some alignment issues * Refined the styles further * Bump versions by dotnet-bump-version. * Fixes v0.4.19! (#855) * Fixed OPDS urls to work with new Filtering schema * Fixed a rendering issue with Language tag when it's null * Fixed a bug where locked covers were resetting during refresh metadata. * Redid all the migrations and put some extra checks due to a bad migration from previous release (EF Core was producing an error). * Fixed a bug which didn't take sort direction when not changing sort field * Default installs now backup daily * Bump versions by dotnet-bump-version. * Fixed a bug in how to determine if the volume or series updates cover image. (#857) * Bump versions by dotnet-bump-version. * More fixes (again) (#858) * Send stack trace to the UI on prod mode * Pdfs will now generate cover images. I missed something a few releases ago. * Bump versions by dotnet-bump-version. * I can't believe it's more fixes! (#863) * Send stack trace to the UI on prod mode * Pdfs will now generate cover images. I missed something a few releases ago. * Ignore @Recently-Snapshot directories for QNAP. * Refactored Bitmap code to use ImageSharp so it's truly cross platform. * Updated pdf extraction to use a multi-threaded approach to greatly speed up pdf image extraction * Hooked in Characters tag from ComicInfo.xml * Bump versions by dotnet-bump-version. * Added check to see if mount folder is empty (#871) * Added check for if mount folder is empty * updating log message * Adding unit test * Bump versions by dotnet-bump-version. * Reader Fixes and Enhancements (#880) * Don't show an exception when bookmarking doesn't have anything to change. * Cleaned up the bookmark code a bit. * Implemented fullscreen mode in the web reader. Refactored User Settings to move Password and 3rd Party Clients to a tab rather than accordion. Removed color filters for web reader. * Implemented fullscreen mode into book reader * Added some code for toggling fullscreen which re-renders the screen to ensure the fitting works optimially * Fixed an issue where moving from FitToScreen -> Split (L/R) wouldn't render the screen correctly due to canvas not being reset. * Fixed bad optimization and scaling when drawing fit to screen * Removed left/right highlights on page direction change in favor for icons. Double arrow will dictate the page change. * Reduced overlay auto close time to 3 seconds * Updated the paginging direction overlay to use icons and colors. Added a blur effect on menus * Removed debug flags * Bump versions by dotnet-bump-version. * Fixed a bug with OPDS feeds not returning back results due to improperly setting up FilterDto. (#882) * Bump versions by dotnet-bump-version. * Bookmark Refactor (#893) * Fixed a bug which didn't take sort direction when not changing sort field * Added foundation for Bookmark refactor * Code broken, need to take a break. Issue is Getting bookmark image needs authentication but UI doesn't send. * Implemented the ability to send bookmarked files to the web. Implemented ability to clear bookmarks on disk on a re-occuring basis. * Updated the bookmark design to have it's own card that is self contained. View bookmarks modal has been updated to better lay out the cards. * Refactored download bookmark codes to select files from bookmark directory directly rather than open underlying files. * Wrote the basic logic to kick start the bookmark migration. Added Installed Version into the DB to allow us to know more accurately when to run migrations * Implemented the ability to change the bookmarks directory * Updated all references to BookmarkDirectory to use setting from the DB. Updated Server Settings page to use 2 col for some rows. * Refactored some code to DirectoryService (hasWriteAccess) and fixed up some unit tests from a previous PR. * Treat folders that start with ._ as blacklisted. * Implemented Reset User preferences. Some extra code to prep for the migration. * Implemented a migration for existing bookmarks to using new filesystem based bookmarks * Bump versions by dotnet-bump-version. * Only admins should be able to create new users (#896) * Bump versions by dotnet-bump-version. * Backup on Migrations (#898) * Refactored how the migrations are run. * A backup will be performed before any migrations. Added additional guards before a sub-module is loaded. * Bump versions by dotnet-bump-version. * Fixed a critical bug where registration was broken for first time flow. Refactored how backup before migrations occured such that it now puts the db in temp. The db will be deleted automatically that night. (#900) * Bump versions by dotnet-bump-version. * Fixed lack of ability to scroll with fullscreen mode (#903) * Bump versions by dotnet-bump-version. * Book Reader Issues (#906) * Refactored the Font Escaping Regex with new unit tests. * Fonts are now properly escaped, somehow a regression was introduced. * Refactored most of the book page loading for the reader into the service. * Fixed a bug where going into fullscreen in non dark mode will cause the background of the reader to go black. Fixed a rendering issue with margin left/right screwing html up. Fixed an issue where line-height: 100% would break book's css, now we remove the styles if they are non-valuable. * Changed how I fixed the black mode in fullscreen * Fixed an issue where anchors wouldn't be colored blue in white mode * Fixed a bug in the code that checks if a filename is a cover where it would choose "backcover" as a cover, despite it not being a valid case. * Validate if ReleaseYear is a valid year and if not, set it to 0 to disable it. * Fixed an issue where some large images could blow out the screen when reading on mobile. Now images will force to be max of width of browser * Put my hack back in for fullscreen putting background color to black * Change forwarded headers from All to explicit names * Fixed an issue where Scheme was not https when it should have been. Now the browser will handle which scheme to request. * Cleaned up the user preferences to stack multiple controls onto one row * Fixed fullscreen scroll issue with progress, but now sticky top is missing. * Corrected the element on which we fullscreen * Bump versions by dotnet-bump-version. * Fixed a bug where loading page on book reader wouldn't scroll to last position (#907) * Bump versions by dotnet-bump-version. * Metadata Optimizations (#910) * Added a tooltip to inform user that format and collection filter selections do not only show for the selected library. * Refactored a lot of code around when we update chapter cover images. Applied an optimization for when we re-calculate volume/series covers, such that it only occurs when the first chapter's image updates. * Updated code to ensure only lastmodified gets refreshed in metadata since it always follows a scan * Optimized how metadata is populated on the series. Instead of re-reading the comicInfos, instead I read the data from the underlying chapter entities. This reduces N additional reads AND enables the ability in the future to show/edit chapter level metadata. * Spelling mistake * Fixed a concurency issue by not selecting Genres from DB. Added a test for long paths. * Fixed a bug in filter where collection tag wasn't populating on load * Cleaned up the logic for changelog to better compare against the installed verison. For nightly users, show the last stable as installed. * Removed some demo code * SplitQuery to allow loading tags much faster for series metadata load. * Bump versions by dotnet-bump-version. * Fixed the book reader off by one issue with loading last page (#911) * Bump versions by dotnet-bump-version. * Misc Fixes (#914) * Fixed the book reader off by one issue with loading last page * Fixed a case where scanner would not delete a series if another series with same name but different format was added in that same scan. * Added some missing tag generation (chapter language and summary) * Bump versions by dotnet-bump-version. * Implemented Publication Status in SeriesMetadata and the ability to filter it. (#915) * Bump versions by dotnet-bump-version. * Book Reader Issue Take 2 (#916) * Implemented Publication Status in SeriesMetadata and the ability to filter it. * Updated the docs for Language on metadata to specify it's a BCP-47 code to match Anansi Project. Fixed a bug with reader from previous PR. * Bump versions by dotnet-bump-version. * Don't tag a series as completed if count is 0. (#917) * Bump versions by dotnet-bump-version. * Last Page Rendering Twice on Web Reader Fix (#920) * Don't tag a series as completed if count is 0. * Removed some dead code and added some spacers for when certain fields are disabled so filter section still looks good. * Fixed a bug where last page of a manga reader would be rendered twice when paging backwards. * Bump versions by dotnet-bump-version. * Metadata Performance Scan (#921) * Refactored updating chapter metadata from ComicInfo into the Scan loop. This let's us avoid an additional N file reads (expensive) in the metadata service, as we already have to read them in the scan loop. * Refactored Series level metadata aggregation into the scan loop. This allows for the batching of DB updates to be much smaller, thus faster without much overhead of GC. * Refactored some of the code for ProcessFile to remove a few redundant if statements * Fixed broken build (#922) * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Misc Fixes and Changes (#927) * Cleaned up a ton of warnings/suggestions from the IDE. * Fixed a bug when clearing the filters some presets could be undone. * Renamed a class in the OPDS spec * Simplified logic for when Fit To Screen rendering logic occurs. It now works always rather than only on cover images. * Give some additional info to the user on what the differences between Library Types are * Don't scan .qpkg folders (QNAP devices) * Refactored some code to enable ability to test CoverImage Test. This is a broken test, test.zip is waiting on an issue in NetVips. * Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary * Cleaned up the regex for the extra issue to be more flexible * Bump versions by dotnet-bump-version. * Bump follow-redirects from 1.13.0 to 1.14.7 in /UI/Web (#929) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.13.0 to 1.14.7. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.13.0...v1.14.7) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fixed a bug in CleanupBookmarks where the Except was deleting all files because the path separators didn't match. (#932) * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Feature/parse scanned files tests (#934) * Fixed a bug in CleanupBookmarks where the Except was deleting all files because the path separators didn't match. * Added unit tests for ParseScannedFiles.cs. * Fixed some unit tests. Parser will now clear out multiple spaces in a row and replace with a single. * Bump versions by dotnet-bump-version. * Performance Enhancements (#937) * Bump versions by dotnet-bump-version. * Unit Tests & New Natural Sort (#941) * Added a lot of tests * More tests! Added a Parser.NormalizePath to normalize all paths within Kavita. * Fixed a bug where MarkChaptersAsUnread implementation wasn't consistent between different files and lead to extra row generation for no reason. * Added more unit tests * Found a better implementation for Natural Sorting. Added tests and validate it works. Next commit will swap out natural Sort for new Extension. * Replaced NaturalSortComparer with OrderByNatural. * Drastically simplified and sped up FindFirstEntry for finding cover images in archives * Initial fix for a epub bug where metadata defines key as absolute path but document uses a relative path. We now have a hack to correct for the epub. * Bump versions by dotnet-bump-version. * Fix image aspect ratio in rare case (#935) * Bump versions by dotnet-bump-version. * Fixed missing handlers for adding a chapter to a reading list from card details modal and adding series to collection from series detail. (#942) * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Metadata Tags (#947) * Implemented the ability to click a metadata tag (in series detail) and load a pre-filtered view. Apply still needs to be implemented (preset load is out of sync with external filter) * Refactored people to properly use typeahead so duplicates don't happen and use an observable chain so we can update the screen correctly * Many refactoring to ensure that the timings for filtering always works * Bump versions by dotnet-bump-version. * Removed a hack that was put in when users complained about a tool improperly tagging. This is not the case for most tools. (#949) * Scanner not merging with series that has LocalizedName match (#950) * When performing a scan, series should group if they share the same localized name as a pre-existing series. * Fixed a bug where a series with a different name and localized name weren't merging with a different set of files with the same naming as localized name. * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Bump versions by dotnet-bump-version. * Reader Fixes (#951) * Normalized paths on download controller and when scan is killed due to missing or empty folders, log a critical error. * Tweaked the query for OnDeck to better promote recently added chapters in a series with read progress, but it's still not perfect. * Fixed an issue where up/down key weren't working unless you clicked on the book explicitly * Fixed an issue where infinite scroller was broken in fullscreen mode * When toggling fullscreen mode on infinite scroller, the current page is retained as current position * Fixed an issue where a double render would occur when we didn't need to render as fit split * Stop showing loader when not using fit split * Bump versions by dotnet-bump-version. * Shakeout testing Fixes (#952) * Cleaned up some old code in download bookmark that could create pointless temp folders. * Fixed a bad http call on reading list remove read and cleaned up the messaging * Undid an optimization in finding cover image due to it perfoming depth first rather than breadth. * Updated CleanComicInfo to have Translators and CoverArtists, which were previously missing. * Renamed Refresh Metadata to Refresh Covers on the UI, given Metadata refresh is done in Scan. * Library detail will now retain the search query in the UI. Reduced the amount of api calls to the backend on load. * Reverted allowing the filter to reside in the UI (even though it does work). * Updated the Age Rating to match the v2.1 spec. * Fixed a bug where progress wasn't being saved * Fixed line height not having any effect due to not applying to children elements in the reader * Fixed some wording for Refresh Covers confirmation * Delete Series will now send an event to the UI informing that series was deleted. * Change Progress widget to show Refreshing Covers for * When we exit early due to potential missing folders/drives in a scan, tell the UI that scan is 100% done. * Fixed manage library not supressing scan loader when a complete came in * Fixed a spelling difference for Publication Status between filter and series detail * Fixed a bug where collection detail page would flash on first load due to duplicate load events * Added bookmarks to backups * Fixed issues where fullscreen mode would break infinite scroller contiunous reader * Bump versions by dotnet-bump-version. * Fixed GetTags having wrong return type defined (#954) * Bump versions by dotnet-bump-version. * Missing Age Ratings (#955) * Fixed GetTags having wrong return type defined * Added missing Age Rating tags * Bump versions by dotnet-bump-version. * Version bump for release (#953) Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Andrew Mackrodt <andrewmackrodt@gmail.com>
This commit is contained in:
parent
7fb41f0945
commit
41096d6dc5
360 changed files with 33758 additions and 8073 deletions
16
UI/Web/src/app/_models/chapter-metadata.ts
Normal file
16
UI/Web/src/app/_models/chapter-metadata.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Person } from "./person";
|
||||
|
||||
export interface ChapterMetadata {
|
||||
id: number;
|
||||
chapterId: number;
|
||||
title: string;
|
||||
year: string;
|
||||
writers: Array<Person>;
|
||||
penciller: Array<Person>;
|
||||
inker: Array<Person>;
|
||||
colorist: Array<Person>;
|
||||
letterer: Array<Person>;
|
||||
coverArtist: Array<Person>;
|
||||
editor: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { MangaFile } from './manga-file';
|
||||
import { Person } from './person';
|
||||
import { Tag } from './tag';
|
||||
|
||||
export interface Chapter {
|
||||
id: number;
|
||||
|
|
@ -16,4 +18,19 @@ export interface Chapter {
|
|||
isSpecial: boolean;
|
||||
title: string;
|
||||
created: string;
|
||||
|
||||
titleName: string;
|
||||
/**
|
||||
* This is only Year and Month, Day is not supported from underlying sources
|
||||
*/
|
||||
releaseDate: string;
|
||||
writers: Array<Person>;
|
||||
penciller: Array<Person>;
|
||||
inker: Array<Person>;
|
||||
colorist: Array<Person>;
|
||||
letterer: Array<Person>;
|
||||
coverArtist: Array<Person>;
|
||||
editor: Array<Person>;
|
||||
publisher: Array<Person>;
|
||||
tags: Array<Tag>;
|
||||
}
|
||||
|
|
|
|||
4
UI/Web/src/app/_models/genre.ts
Normal file
4
UI/Web/src/app/_models/genre.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface Genre {
|
||||
id: number,
|
||||
title: string;
|
||||
}
|
||||
6
UI/Web/src/app/_models/metadata/age-rating-dto.ts
Normal file
6
UI/Web/src/app/_models/metadata/age-rating-dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { AgeRating } from "./age-rating";
|
||||
|
||||
export interface AgeRatingDto {
|
||||
value: AgeRating;
|
||||
title: string;
|
||||
}
|
||||
15
UI/Web/src/app/_models/metadata/age-rating.ts
Normal file
15
UI/Web/src/app/_models/metadata/age-rating.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export enum AgeRating {
|
||||
Unknown = 0,
|
||||
AdultsOnly = 1,
|
||||
EarlyChildhood = 2,
|
||||
Everyone = 3,
|
||||
Everyone10Plus = 4,
|
||||
G = 5,
|
||||
KidsToAdults = 6,
|
||||
Mature = 7,
|
||||
Mature15Plus = 8,
|
||||
Mature17Plus = 9,
|
||||
RatingPending = 10,
|
||||
Teen = 11,
|
||||
X18Plus = 12
|
||||
}
|
||||
4
UI/Web/src/app/_models/metadata/language.ts
Normal file
4
UI/Web/src/app/_models/metadata/language.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface Language {
|
||||
isoCode: string;
|
||||
title: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PublicationStatus } from "./publication-status";
|
||||
|
||||
export interface PublicationStatusDto {
|
||||
value: PublicationStatus;
|
||||
title: string;
|
||||
}
|
||||
5
UI/Web/src/app/_models/metadata/publication-status.ts
Normal file
5
UI/Web/src/app/_models/metadata/publication-status.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum PublicationStatus {
|
||||
OnGoing = 0,
|
||||
Hiatus = 1,
|
||||
Completed = 2
|
||||
}
|
||||
|
|
@ -4,4 +4,5 @@ export interface PageBookmark {
|
|||
seriesId: number;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
fileName: string;
|
||||
}
|
||||
|
|
@ -1,10 +1,20 @@
|
|||
export enum PersonRole {
|
||||
Other = 0,
|
||||
Author = 1,
|
||||
Artist = 2
|
||||
Other = 1,
|
||||
Artist = 2,
|
||||
Writer = 3,
|
||||
Penciller = 4,
|
||||
Inker = 5,
|
||||
Colorist = 6,
|
||||
Letterer = 7,
|
||||
CoverArtist = 8,
|
||||
Editor = 9,
|
||||
Publisher = 10,
|
||||
Character = 11,
|
||||
Translator = 12
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
role: PersonRole;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import { PageSplitOption } from './page-split-option';
|
||||
import { READER_MODE } from './reader-mode';
|
||||
import { ReadingDirection } from './reading-direction';
|
||||
|
|
|
|||
|
|
@ -1,38 +1,70 @@
|
|||
import { MangaFormat } from "./manga-format";
|
||||
|
||||
export interface FilterItem {
|
||||
export interface FilterItem<T> {
|
||||
title: string;
|
||||
value: any;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface SeriesFilter {
|
||||
mangaFormat: MangaFormat | null;
|
||||
formats: Array<MangaFormat>;
|
||||
libraries: Array<number>,
|
||||
readStatus: ReadStatus;
|
||||
genres: Array<number>;
|
||||
writers: Array<number>;
|
||||
penciller: Array<number>;
|
||||
inker: Array<number>;
|
||||
colorist: Array<number>;
|
||||
letterer: Array<number>;
|
||||
coverArtist: Array<number>;
|
||||
editor: Array<number>;
|
||||
publisher: Array<number>;
|
||||
character: Array<number>;
|
||||
translators: Array<number>;
|
||||
collectionTags: Array<number>;
|
||||
rating: number;
|
||||
ageRating: Array<number>;
|
||||
sortOptions: SortOptions | null;
|
||||
tags: Array<number>;
|
||||
languages: Array<string>;
|
||||
publicationStatus: Array<number>;
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
sortField: SortField;
|
||||
isAscending: boolean;
|
||||
}
|
||||
|
||||
export enum SortField {
|
||||
SortName = 1,
|
||||
Created = 2,
|
||||
LastModified = 3
|
||||
}
|
||||
|
||||
export interface ReadStatus {
|
||||
notRead: boolean,
|
||||
inProgress: boolean,
|
||||
read: boolean,
|
||||
}
|
||||
|
||||
export const mangaFormatFilters = [
|
||||
{
|
||||
title: 'Format: All',
|
||||
value: null,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'Format: Images',
|
||||
title: 'Images',
|
||||
value: MangaFormat.IMAGE,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'Format: EPUB',
|
||||
title: 'EPUB',
|
||||
value: MangaFormat.EPUB,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'Format: PDF',
|
||||
title: 'PDF',
|
||||
value: MangaFormat.PDF,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'Format: ARCHIVE',
|
||||
title: 'ARCHIVE',
|
||||
value: MangaFormat.ARCHIVE,
|
||||
selected: false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
import { CollectionTag } from "./collection-tag";
|
||||
import { Genre } from "./genre";
|
||||
import { AgeRating } from "./metadata/age-rating";
|
||||
import { PublicationStatus } from "./metadata/publication-status";
|
||||
import { Person } from "./person";
|
||||
import { Tag } from "./tag";
|
||||
|
||||
export interface SeriesMetadata {
|
||||
publisher: string;
|
||||
genres: Array<string>;
|
||||
tags: Array<CollectionTag>;
|
||||
persons: Array<Person>;
|
||||
summary: string;
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
collectionTags: Array<CollectionTag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
ageRating: AgeRating;
|
||||
releaseYear: number;
|
||||
language: string;
|
||||
seriesId: number;
|
||||
publicationStatus: PublicationStatus;
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ export interface Series {
|
|||
originalName: string; // This is not shown to user
|
||||
localizedName: string;
|
||||
sortName: string;
|
||||
summary: string;
|
||||
coverImageLocked: boolean;
|
||||
volumes: Volume[];
|
||||
pages: number; // Total pages in series
|
||||
|
|
|
|||
4
UI/Web/src/app/_models/tag.ts
Normal file
4
UI/Web/src/app/_models/tag.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface Tag {
|
||||
id: number,
|
||||
title: string;
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ export class ActionFactoryService {
|
|||
|
||||
this.seriesActions.push({
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'Refresh Metadata',
|
||||
title: 'Refresh Covers',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
|
@ -114,7 +114,7 @@ export class ActionFactoryService {
|
|||
|
||||
this.libraryActions.push({
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'Refresh Metadata',
|
||||
title: 'Refresh Covers',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type VolumeActionCallback = (volume: Volume) => void;
|
|||
export type ChapterActionCallback = (chapter: Chapter) => void;
|
||||
export type ReadingListActionCallback = (readingList: ReadingList) => void;
|
||||
export type VoidActionCallback = () => void;
|
||||
export type BooleanActionCallback = (result: boolean) => void;
|
||||
|
||||
/**
|
||||
* Responsible for executing actions
|
||||
|
|
@ -57,7 +58,7 @@ export class ActionService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
this.libraryService.scan(library?.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Scan started for ' + library.name);
|
||||
this.toastr.success('Scan queued for ' + library.name);
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
|
@ -75,12 +76,15 @@ export class ActionService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (!await this.confirmService.confirm('Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.success('Scan started for ' + library.name);
|
||||
this.toastr.success('Scan queued for ' + library.name);
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
|
@ -124,7 +128,7 @@ export class ActionService implements OnDestroy {
|
|||
*/
|
||||
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);
|
||||
this.toastr.success('Scan queued for ' + series.name);
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
|
@ -137,7 +141,10 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
async refreshMetdata(series: Series, callback?: SeriesActionCallback) {
|
||||
if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (!await this.confirmService.confirm('Refresh covers will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -484,4 +491,20 @@ export class ActionService implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteSeries(series: Series, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) {
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||
if (callback) {
|
||||
this.toastr.success('Series deleted');
|
||||
callback(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { AccountService } from './account.service';
|
||||
import { NavService } from './nav.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ImageService {
|
||||
export class ImageService implements OnDestroy {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
apiKey: string = '';
|
||||
public placeholderImage = 'assets/images/image-placeholder-min.png';
|
||||
public errorImage = 'assets/images/error-placeholder2-min.png';
|
||||
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
|
||||
|
||||
constructor(private navSerivce: NavService) {
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
constructor(private navSerivce: NavService, private accountService: AccountService) {
|
||||
this.navSerivce.darkMode$.subscribe(res => {
|
||||
if (res) {
|
||||
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||
|
|
@ -22,6 +28,17 @@ export class ImageService {
|
|||
this.errorImage = 'assets/images/error-placeholder2-min.png';
|
||||
}
|
||||
});
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
||||
if (user) {
|
||||
this.apiKey = user.apiKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
getVolumeCoverImage(volumeId: number) {
|
||||
|
|
@ -41,7 +58,7 @@ export class ImageService {
|
|||
}
|
||||
|
||||
getBookmarkedImage(chapterId: number, pageNum: number) {
|
||||
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum;
|
||||
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
|
||||
}
|
||||
|
||||
updateErroredImage(event: any) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export enum EVENTS {
|
|||
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
||||
ScanLibraryError = 'ScanLibraryError',
|
||||
BackupDatabaseProgress = 'BackupDatabaseProgress',
|
||||
CleanupProgress = 'CleanupProgress'
|
||||
CleanupProgress = 'CleanupProgress',
|
||||
DownloadProgress = 'DownloadProgress'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
|
|
@ -38,7 +39,6 @@ export interface Message<T> {
|
|||
export class MessageHubService {
|
||||
hubUrl = environment.hubUrl;
|
||||
private hubConnection!: HubConnection;
|
||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
private messagesSource = new ReplaySubject<Message<any>>(1);
|
||||
public messages$ = this.messagesSource.asObservable();
|
||||
|
|
@ -53,7 +53,7 @@ export class MessageHubService {
|
|||
|
||||
isAdmin: boolean = false;
|
||||
|
||||
constructor(private modalService: NgbModal, private toastr: ToastrService, private router: Router) {
|
||||
constructor(private toastr: ToastrService, private router: Router) {
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +106,13 @@ export class MessageHubService {
|
|||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.DownloadProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.DownloadProgress,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.RefreshMetadataProgress,
|
||||
|
|
@ -162,16 +169,6 @@ export class MessageHubService {
|
|||
event: EVENTS.UpdateAvailable,
|
||||
payload: resp.body
|
||||
});
|
||||
// Ensure only 1 instance of UpdateNotificationModal can be open at once
|
||||
if (this.updateNotificationModalRef != null) { return; }
|
||||
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
this.updateNotificationModalRef.componentInstance.updateData = resp.body;
|
||||
this.updateNotificationModalRef.closed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
this.updateNotificationModalRef.dismissed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
87
UI/Web/src/app/_services/metadata.service.ts
Normal file
87
UI/Web/src/app/_services/metadata.service.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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 { ChapterMetadata } from '../_models/chapter-metadata';
|
||||
import { Genre } from '../_models/genre';
|
||||
import { AgeRating } from '../_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||
import { Language } from '../_models/metadata/language';
|
||||
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
|
||||
import { Person } from '../_models/person';
|
||||
import { Tag } from '../_models/tag';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MetadataService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getAgeRating(ageRating: AgeRating) {
|
||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||
return of(this.ageRatingTypes[ageRating]);
|
||||
}
|
||||
return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => {
|
||||
if (this.ageRatingTypes === undefined) {
|
||||
this.ageRatingTypes = {};
|
||||
}
|
||||
|
||||
this.ageRatingTypes[ageRating] = ratingString;
|
||||
return this.ageRatingTypes[ageRating];
|
||||
}));
|
||||
}
|
||||
|
||||
getAllAgeRatings(libraries?: Array<number>) {
|
||||
let method = 'metadata/age-ratings'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<AgeRatingDto>>(this.baseUrl + method);;
|
||||
}
|
||||
|
||||
getAllPublicationStatus(libraries?: Array<number>) {
|
||||
let method = 'metadata/publication-status'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<PublicationStatusDto>>(this.baseUrl + method);;
|
||||
}
|
||||
|
||||
getAllTags(libraries?: Array<number>) {
|
||||
let method = 'metadata/tags'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Array<Tag>>(this.baseUrl + method);;
|
||||
}
|
||||
|
||||
getAllGenres(libraries?: Array<number>) {
|
||||
let method = 'metadata/genres'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Genre[]>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllLanguages(libraries?: Array<number>) {
|
||||
let method = 'metadata/languages'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Language[]>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllPeople(libraries?: Array<number>) {
|
||||
let method = 'metadata/people'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
method += '?libraryIds=' + libraries.join(',');
|
||||
}
|
||||
return this.httpClient.get<Person[]>(this.baseUrl + method);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
|
|
@ -13,7 +13,10 @@ export class NavService {
|
|||
private darkModeSource = new ReplaySubject<boolean>(1);
|
||||
darkMode$ = this.darkModeSource.asObservable();
|
||||
|
||||
constructor() {
|
||||
private renderer: Renderer2;
|
||||
|
||||
constructor(rendererFactory: RendererFactory2) {
|
||||
this.renderer = rendererFactory.createRenderer(null, null);
|
||||
this.showNavBar();
|
||||
}
|
||||
|
||||
|
|
@ -27,13 +30,23 @@ export class NavService {
|
|||
|
||||
toggleDarkMode() {
|
||||
this.darkMode = !this.darkMode;
|
||||
this.updateColorScheme();
|
||||
this.darkModeSource.next(this.darkMode);
|
||||
}
|
||||
|
||||
setDarkMode(mode: boolean) {
|
||||
this.darkMode = mode;
|
||||
this.updateColorScheme();
|
||||
this.darkModeSource.next(this.darkMode);
|
||||
}
|
||||
|
||||
private updateColorScheme() {
|
||||
if (this.darkMode) {
|
||||
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'dark');
|
||||
} else {
|
||||
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'light');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,4 +193,34 @@ export class ReaderService {
|
|||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
enterFullscreen(el: Element, callback?: VoidFunction) {
|
||||
if (!document.fullscreenElement) {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().then(() => {
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitFullscreen(callback?: VoidFunction) {
|
||||
if (document.exitFullscreen && this.checkFullscreenMode()) {
|
||||
document.exitFullscreen().then(() => {
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns If document is in fullscreen mode
|
||||
*/
|
||||
checkFullscreenMode() {
|
||||
return document.fullscreenElement != null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export class ReadingListService {
|
|||
}
|
||||
|
||||
removeRead(readingListId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, { responseType: 'text' as 'json' });
|
||||
return this.httpClient.post<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, { responseType: 'text' as 'json' });
|
||||
}
|
||||
|
||||
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { CollectionTag } from '../_models/collection-tag';
|
|||
import { InProgressChapter } from '../_models/in-progress-chapter';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { ReadStatus, SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesMetadata } from '../_models/series-metadata';
|
||||
import { Volume } from '../_models/volume';
|
||||
import { ImageService } from './image.service';
|
||||
|
|
@ -39,6 +39,18 @@ export class SeriesService {
|
|||
return paginatedVariable;
|
||||
}
|
||||
|
||||
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
let params = new HttpParams();
|
||||
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
const data = this.createSeriesFilter(filter);
|
||||
|
||||
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
return this._cachePaginatedResults(response, this.paginatedResults);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
let params = new HttpParams();
|
||||
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
|
@ -137,7 +149,7 @@ export class SeriesService {
|
|||
|
||||
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));
|
||||
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));
|
||||
return items;
|
||||
}));
|
||||
}
|
||||
|
|
@ -177,13 +189,35 @@ export class SeriesService {
|
|||
|
||||
createSeriesFilter(filter?: SeriesFilter) {
|
||||
const data: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
formats: [],
|
||||
libraries: [],
|
||||
genres: [],
|
||||
writers: [],
|
||||
penciller: [],
|
||||
inker: [],
|
||||
colorist: [],
|
||||
letterer: [],
|
||||
coverArtist: [],
|
||||
editor: [],
|
||||
publisher: [],
|
||||
character: [],
|
||||
translators: [],
|
||||
collectionTags: [],
|
||||
rating: 0,
|
||||
readStatus: {
|
||||
read: true,
|
||||
inProgress: true,
|
||||
notRead: true
|
||||
},
|
||||
sortOptions: null,
|
||||
ageRating: [],
|
||||
tags: [],
|
||||
languages: [],
|
||||
publicationStatus: [],
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
data.mangaFormat = filter.mangaFormat;
|
||||
}
|
||||
if (filter === undefined) return data;
|
||||
|
||||
return data;
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
<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>
|
||||
|
|
@ -50,6 +51,6 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-info" href="https://wiki.kavitareader.com/en/guides/adding-a-library" target="_blank">Help</a>
|
||||
<a class="btn btn-info" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, Input, 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';
|
||||
|
|
@ -17,6 +17,12 @@ export interface DirectoryPickerResult {
|
|||
})
|
||||
export class DirectoryPickerComponent implements OnInit {
|
||||
|
||||
@Input() startingFolder: string = '';
|
||||
/**
|
||||
* Url to give more information about selecting directories. Passing nothing will suppress.
|
||||
*/
|
||||
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library';
|
||||
|
||||
currentRoot = '';
|
||||
folders: string[] = [];
|
||||
routeStack: Stack<string> = new Stack<string>();
|
||||
|
|
@ -27,7 +33,22 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadChildren(this.currentRoot);
|
||||
if (this.startingFolder && this.startingFolder.length > 0) {
|
||||
let folders = this.startingFolder.split('/');
|
||||
let folders2 = this.startingFolder.split('\\');
|
||||
if (folders.length === 1 && folders2.length > 1) {
|
||||
folders = folders2;
|
||||
}
|
||||
if (!folders[0].endsWith('/')) {
|
||||
folders[0] = folders[0] + '/';
|
||||
}
|
||||
folders.forEach(folder => this.routeStack.push(folder));
|
||||
|
||||
const fullPath = this.routeStack.items.join('/');
|
||||
this.loadChildren(fullPath);
|
||||
} else {
|
||||
this.loadChildren(this.currentRoot);
|
||||
}
|
||||
}
|
||||
|
||||
filterFolder = (folder: string) => {
|
||||
|
|
@ -38,7 +59,7 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
this.currentRoot = folderName;
|
||||
this.routeStack.push(folderName);
|
||||
const fullPath = this.routeStack.items.join('/');
|
||||
this.loadChildren(fullPath);
|
||||
this.loadChildren(fullPath);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
|
|
@ -86,7 +107,7 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
if (lastPath && lastPath != path) {
|
||||
let replaced = path.replace(lastPath, '');
|
||||
if (replaced.startsWith('/') || replaced.startsWith('\\')) {
|
||||
replaced = replaced.substr(1, replaced.length);
|
||||
replaced = replaced.substring(1, replaced.length);
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
|
@ -95,14 +116,11 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
}
|
||||
|
||||
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++) {
|
||||
while(this.routeStack.items.length - 1 > index) {
|
||||
this.routeStack.pop();
|
||||
}
|
||||
|
||||
this.loadChildren(this.routeStack.peek() || '');
|
||||
|
||||
const fullPath = this.routeStack.items.join('/');
|
||||
this.loadChildren(fullPath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="library-type">Type</label>
|
||||
<select class="form-control" id="library-type" formControlName="type" [attr.disabled]="this.library">
|
||||
<label for="library-type">Type</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #typeTooltip>Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</ng-template>
|
||||
<span class="sr-only" id="library-type-help">Library type determines how filenames are parsed and if the UI shows Chapters (Manga) vs Issues (Comics). Book work the same way as Manga but fall back to embedded data.</span>
|
||||
<select class="form-control" id="library-type" formControlName="type" [attr.disabled]="this.library" aria-describedby="library-type-help">
|
||||
<option [value]="i" *ngFor="let opt of libraryTypes; let i = index">{{opt}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ export interface ServerSettings {
|
|||
enableOpds: boolean;
|
||||
enableAuthentication: boolean;
|
||||
baseUrl: string;
|
||||
bookmarksDirectory: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<div class="card w-100 mb-2" style="width: 18rem;">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{{update.updateTitle}}
|
||||
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
|
||||
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
|
||||
<span class="badge badge-secondary" *ngIf="update.updateVersion === installedVersion">Installed</span>
|
||||
<span class="badge badge-secondary" *ngIf="update.updateVersion > installedVersion">Available</span>
|
||||
</h4>
|
||||
<h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
|
||||
|
||||
|
||||
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
|
||||
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,26 @@ export class ChangelogComponent implements OnInit {
|
|||
|
||||
updates: Array<UpdateVersionEvent> = [];
|
||||
isLoading: boolean = true;
|
||||
installedVersion: string = '';
|
||||
|
||||
constructor(private serverService: ServerService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.serverService.getChangelog().subscribe(updates => {
|
||||
this.updates = updates;
|
||||
this.isLoading = false;
|
||||
|
||||
this.serverService.getServerInfo().subscribe(info => {
|
||||
this.installedVersion = info.kavitaVersion;
|
||||
this.serverService.getChangelog().subscribe(updates => {
|
||||
this.updates = updates;
|
||||
this.isLoading = false;
|
||||
|
||||
if (this.updates.filter(u => u.updateVersion === this.installedVersion).length === 0) {
|
||||
// User is on a nightly version. Tell them the last stable is installed
|
||||
this.installedVersion = this.updates[0].updateVersion;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,14 +39,16 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||
// when a progress event comes in, show it on the UI next to library
|
||||
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (event.event !== EVENTS.ScanLibraryProgress) return;
|
||||
|
||||
console.log('scan event: ', event.payload);
|
||||
|
||||
const scanEvent = event.payload as ProgressEvent;
|
||||
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
||||
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 1};
|
||||
if (scanEvent.progress === 0) {
|
||||
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;
|
||||
}
|
||||
|
||||
if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 100) {
|
||||
if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 1) {
|
||||
this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => {
|
||||
const newLibrary = libraries.find(lib => lib.id === scanEvent.libraryId);
|
||||
const existingLibrary = this.libraries.find(lib => lib.id === scanEvent.libraryId);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,20 @@
|
|||
<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-bookmarksdir">Bookmarks Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
|
||||
<span class="sr-only" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
|
||||
<div class="input-group-append">
|
||||
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="form-group">
|
||||
<label for="settings-baseurl">Base Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
|
||||
|
|
@ -15,20 +29,22 @@
|
|||
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
|
||||
</div> -->
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-port">Port</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
|
||||
<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> <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 class="row no-gutters">
|
||||
<div class="form-group col-md-6 col-sm-12 pr-2">
|
||||
<label for="settings-port">Port</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
|
||||
<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 col-md-6 col-sm-12">
|
||||
<label for="logging-level-port">Logging Level</label> <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>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -78,6 +94,7 @@
|
|||
</div>
|
||||
|
||||
<div class="float-right">
|
||||
<button type="button" class="btn btn-secondary mr-2" (click)="resetToDefaults()">Reset to Default</button>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
|
||||
@Component({
|
||||
|
|
@ -18,7 +20,8 @@ export class ManageSettingsComponent implements OnInit {
|
|||
taskFrequencies: Array<string> = [];
|
||||
logLevels: Array<string> = [];
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { }
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService,
|
||||
private modalService: NgbModal) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
|
||||
|
|
@ -30,6 +33,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
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('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [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]));
|
||||
|
|
@ -43,6 +47,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
|
||||
resetForm() {
|
||||
this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory);
|
||||
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
|
||||
this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan);
|
||||
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
|
||||
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
|
||||
|
|
@ -77,4 +82,26 @@ export class ManageSettingsComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
openDirectoryChooser(existingDirectory: string, formControl: string) {
|
||||
const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' });
|
||||
modalRef.componentInstance.startingFolder = existingDirectory || '';
|
||||
modalRef.componentInstance.helpUrl = '';
|
||||
modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => {
|
||||
if (closeResult.success) {
|
||||
this.settingsForm.get(formControl)?.setValue(closeResult.folderPath);
|
||||
this.settingsForm.markAsTouched();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export class SettingsService {
|
|||
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
|
||||
}
|
||||
|
||||
resetServerSettings() {
|
||||
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset', {});
|
||||
}
|
||||
|
||||
getTaskFrequencies() {
|
||||
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
|
||||
}
|
||||
|
|
|
|||
14
UI/Web/src/app/all-series/all-series.component.html
Normal file
14
UI/Web/src/app/all-series/all-series.component.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout header="All Series"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[actions]="actions"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
0
UI/Web/src/app/all-series/all-series.component.scss
Normal file
0
UI/Web/src/app/all-series/all-series.component.scss
Normal file
145
UI/Web/src/app/all-series/all-series.component.ts
Normal file
145
UI/Web/src/app/all-series/all-series.component.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take, debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { ActionItem, Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { MessageHubService } from '../_services/message-hub.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-series',
|
||||
templateUrl: './all-series.component.html',
|
||||
styleUrls: ['./all-series.component.scss']
|
||||
})
|
||||
export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||
|
||||
series: Series[] = [];
|
||||
loadingSeries = false;
|
||||
pagination!: Pagination;
|
||||
actions: ActionItem<Library>[] = [];
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
|
||||
switch (action) {
|
||||
case Action.AddToReadingList:
|
||||
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.AddToCollection:
|
||||
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private router: Router, private seriesService: SeriesService,
|
||||
private titleService: Title, private actionService: ActionService,
|
||||
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
|
||||
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.titleService.setTitle('Kavita - All Series');
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => {
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
this.loadPage();
|
||||
}
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
const page = this.getPage();
|
||||
if (page != null) {
|
||||
this.pagination.currentPage = parseInt(page, 10);
|
||||
}
|
||||
this.loadingSeries = true;
|
||||
|
||||
this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
this.pagination = series.pagination;
|
||||
this.loadingSeries = false;
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(pagination: Pagination) {
|
||||
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage);
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`;
|
||||
|
||||
getPage() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('page');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { AuthGuard } from './_guards/auth.guard';
|
|||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { AdminGuard } from './_guards/admin.guard';
|
||||
|
||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||
|
||||
|
|
@ -16,18 +18,22 @@ const routes: Routes = [
|
|||
{path: '', component: UserLoginComponent},
|
||||
{
|
||||
path: 'admin',
|
||||
canActivate: [AdminGuard],
|
||||
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
|
||||
},
|
||||
{
|
||||
path: 'collections',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule)
|
||||
},
|
||||
{
|
||||
path: 'preferences',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./user-settings/user-settings.module').then(m => m.UserSettingsModule)
|
||||
},
|
||||
{
|
||||
path: 'lists',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./reading-list/reading-list.module').then(m => m.ReadingListModule)
|
||||
},
|
||||
{
|
||||
|
|
@ -55,6 +61,8 @@ const routes: Routes = [
|
|||
{path: 'library', component: DashboardComponent},
|
||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||
{path: 'on-deck', component: OnDeckComponent},
|
||||
{path: 'all-series', component: AllSeriesComponent},
|
||||
|
||||
]
|
||||
},
|
||||
{path: 'login', component: UserLoginComponent},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LibraryService } from './_services/library.service';
|
|||
import { MessageHubService } from './_services/message-hub.service';
|
||||
import { NavService } from './_services/nav.service';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
|
@ -17,7 +17,11 @@ export class AppComponent implements OnInit {
|
|||
|
||||
constructor(private accountService: AccountService, public navService: NavService,
|
||||
private messageHub: MessageHubService, private libraryService: LibraryService,
|
||||
private router: Router, private ngbModal: NgbModal) {
|
||||
private router: Router, private ngbModal: NgbModal, private ratingConfig: NgbRatingConfig) {
|
||||
|
||||
// Setup default rating config
|
||||
ratingConfig.max = 5;
|
||||
ratingConfig.resettable = true;
|
||||
|
||||
// Close any open modals when a route change occurs
|
||||
router.events
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { AutocompleteLibModule } from 'angular-ng-autocomplete';
|
|||
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
|
||||
import { CarouselModule } from './carousel/carousel.module';
|
||||
|
||||
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
||||
import { TypeaheadModule } from './typeahead/typeahead.module';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||
|
|
@ -33,6 +32,10 @@ import { ReadingListModule } from './reading-list/reading-list.module';
|
|||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||
import { ConfigData } from './_models/config-data';
|
||||
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
||||
import { PersonRolePipe } from './person-role.pipe';
|
||||
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { PublicationStatusPipe } from './publication-status.pipe';
|
||||
|
||||
|
||||
@NgModule({
|
||||
|
|
@ -45,11 +48,14 @@ import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.
|
|||
SeriesDetailComponent,
|
||||
NotConnectedComponent, // Move into ExtrasModule
|
||||
ReviewSeriesModalComponent,
|
||||
PersonBadgeComponent,
|
||||
RecentlyAddedComponent,
|
||||
OnDeckComponent,
|
||||
DashboardComponent,
|
||||
NavEventsToggleComponent,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe,
|
||||
SeriesMetadataDetailComponent,
|
||||
AllSeriesComponent,
|
||||
],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="container-flex {{darkMode ? 'dark-mode' : ''}}">
|
||||
<div class="container-flex {{darkMode ? 'dark-mode' : ''}} reader-container" tabindex="0" #reader>
|
||||
<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>
|
||||
|
|
@ -57,6 +57,17 @@
|
|||
<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"> {{clickToPaginate ? 'On' : 'Off'}}</span></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label id="fullscreen">Fullscreen <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="fullscreenTooltip" role="button" tabindex="0" aria-describedby="fullscreen-help"></i></label>
|
||||
<ng-template #fullscreenTooltip>Put reader in fullscreen mode</ng-template>
|
||||
<span class="sr-only" id="fullscreen-help">
|
||||
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
||||
</span>
|
||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||
<span *ngIf="darkMode"> {{isFullscreen ? 'Exit' : 'Enter'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row no-gutters justify-content-between">
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
|
||||
</div>
|
||||
|
|
@ -83,7 +94,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
<ng-template #nestedChildren>
|
||||
<ul *ngFor="let chapterGroup of chapters" style="padding-inline-start: 0px">
|
||||
<ul *ngFor="let chapterGroup of chapters" class="chapter-title">
|
||||
<li class="{{chapterGroup.page == pageNum ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
|
||||
{{chapterGroup.title}}
|
||||
</li>
|
||||
|
|
@ -99,13 +110,14 @@
|
|||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
||||
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px', 'margin': '0px 0px'}" [innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}"
|
||||
[@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
||||
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
<div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px', 'margin': '0px 0px'}"
|
||||
[innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate" tabindex="-1"></div>
|
||||
<div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate" tabindex="-1"></div>
|
||||
|
||||
<div *ngIf="page !== undefined && scrollbarNeeded">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
|
|
|
|||
|
|
@ -154,18 +154,40 @@ $primary-color: #0062cc;
|
|||
}
|
||||
|
||||
.reading-section {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
width: 100%;
|
||||
//overflow: auto; // This will break progress reporting
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
outline: none; // Only the reading section itself shouldn't receive any outline. We use it to shift focus in fullscreen mode
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.book-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// A bunch of resets so books render correctly
|
||||
::ng-deep .book-content {
|
||||
& a, & :link {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
padding-inline-start: 0px
|
||||
}
|
||||
|
||||
::ng-deep .scale-width {
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
}
|
||||
|
||||
|
||||
// Click to Paginate styles
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AfterViewInit, Component, ElementRef, HostListener, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core';
|
||||
import {Location} from '@angular/common';
|
||||
import { AfterViewInit, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core';
|
||||
import {DOCUMENT, Location} from '@angular/common';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
|
@ -111,6 +111,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
@ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('reader', {static: true}) reader!: ElementRef;
|
||||
|
||||
/**
|
||||
* Next Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking).
|
||||
|
|
@ -185,6 +186,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
*/
|
||||
originalBodyColor: string | undefined;
|
||||
|
||||
/**
|
||||
* If the web browser is in fullscreen mode
|
||||
*/
|
||||
isFullscreen: boolean = false;
|
||||
|
||||
darkModeStyles = `
|
||||
*:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) {
|
||||
color: #dcdcdc !important;
|
||||
|
|
@ -237,7 +243,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
|
||||
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
|
||||
private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService,
|
||||
private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService) {
|
||||
private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService,
|
||||
@Inject(DOCUMENT) private document: Document) {
|
||||
this.navService.hideNavBar();
|
||||
|
||||
this.darkModeStyleElem = this.renderer.createElement('style');
|
||||
|
|
@ -275,7 +282,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
const bodyNode = document.querySelector('body');
|
||||
const bodyNode = this.document.querySelector('body');
|
||||
if (bodyNode !== undefined && bodyNode !== null) {
|
||||
this.originalBodyColor = bodyNode.style.background;
|
||||
}
|
||||
|
|
@ -290,14 +297,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
*/
|
||||
ngAfterViewInit() {
|
||||
// check scroll offset and if offset is after any of the "id" markers, save progress
|
||||
fromEvent(window, 'scroll')
|
||||
fromEvent(this.reader.nativeElement, 'scroll')
|
||||
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (this.isLoading) return;
|
||||
|
||||
// Highlight the current chapter we are on
|
||||
if (Object.keys(this.pageAnchors).length !== 0) {
|
||||
// get the height of the document so we can capture markers that are halfway on the document viewport
|
||||
const verticalOffset = this.scrollService.scrollPosition + (document.body.offsetHeight / 2);
|
||||
const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2);
|
||||
|
||||
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
||||
if (alreadyReached.length > 0) {
|
||||
|
|
@ -344,7 +351,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
const bodyNode = document.querySelector('body');
|
||||
const bodyNode = this.document.querySelector('body');
|
||||
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
|
||||
bodyNode.style.background = this.originalBodyColor;
|
||||
if (this.user.preferences.siteDarkMode) {
|
||||
|
|
@ -353,7 +360,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
this.navService.showNavBar();
|
||||
|
||||
const head = document.querySelector('head');
|
||||
const head = this.document.querySelector('head');
|
||||
this.renderer.removeChild(head, this.darkModeStyleElem);
|
||||
|
||||
if (this.clickToPaginateVisualOverlayTimeout !== undefined) {
|
||||
|
|
@ -365,6 +372,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.clickToPaginateVisualOverlayTimeout2 = undefined;
|
||||
}
|
||||
|
||||
this.readerService.exitFullscreen();
|
||||
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
|
@ -573,8 +582,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
resetSettings() {
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
|| this.document.documentElement.clientWidth
|
||||
|| this.document.body.clientWidth;
|
||||
|
||||
let margin = '15%';
|
||||
if (windowWidth <= 700) {
|
||||
|
|
@ -623,7 +632,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
moveFocus() {
|
||||
const elems = document.getElementsByClassName('reading-section');
|
||||
const elems = this.document.getElementsByClassName('reading-section');
|
||||
if (elems.length > 0) {
|
||||
(elems[0] as HTMLDivElement).focus();
|
||||
}
|
||||
|
|
@ -671,10 +680,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
getPageMarkers(ids: Array<string>) {
|
||||
try {
|
||||
return document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', '));
|
||||
return this.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(', '));
|
||||
return this.document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -709,6 +718,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.setupPage(part, scrollTop);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply scaling class to all images to ensure they scale down to max width to not blow out the reader
|
||||
Array.from(imgs).forEach(img => this.renderer.addClass(img, 'scale-width'));
|
||||
|
||||
Promise.all(Array.from(imgs)
|
||||
.filter(img => !img.complete)
|
||||
.map(img => new Promise(resolve => { img.onload = img.onerror = resolve; })))
|
||||
|
|
@ -730,16 +743,19 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (part !== undefined && part !== '') {
|
||||
this.scrollTo(part);
|
||||
} else if (scrollTop !== undefined && scrollTop !== 0) {
|
||||
this.scrollService.scrollTo(scrollTop);
|
||||
this.scrollService.scrollTo(scrollTop, this.reader.nativeElement);
|
||||
} else {
|
||||
this.scrollService.scrollTo(0);
|
||||
this.scrollService.scrollTo(0, this.reader.nativeElement);
|
||||
}
|
||||
|
||||
// we need to click the document before arrow keys will scroll down.
|
||||
this.reader.nativeElement.focus();
|
||||
}
|
||||
|
||||
setPageNum(pageNum: number) {
|
||||
if (pageNum < 0) {
|
||||
this.pageNum = 0;
|
||||
} else if (pageNum >= this.maxPages - 1) {
|
||||
} else if (pageNum >= this.maxPages) {
|
||||
this.pageNum = this.maxPages - 1;
|
||||
} else {
|
||||
this.pageNum = pageNum;
|
||||
|
|
@ -799,13 +815,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.setPageNum(this.pageNum - 1);
|
||||
}
|
||||
|
||||
if (this.pageNum >= this.maxPages - 1) {
|
||||
if (oldPageNum + 1 === this.maxPages) {
|
||||
// Move to next volume/chapter automatically
|
||||
this.loadNextChapter();
|
||||
}
|
||||
|
||||
if (oldPageNum === this.pageNum) { return; }
|
||||
|
||||
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
|
|
@ -860,13 +877,27 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
updateReaderStyles() {
|
||||
if (this.readingHtml != undefined && this.readingHtml.nativeElement) {
|
||||
Object.entries(this.pageStyles).forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]);
|
||||
return;
|
||||
}
|
||||
this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important);
|
||||
});
|
||||
|
||||
for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) {
|
||||
const elem = this.readingHtml.nativeElement.children.item(i);
|
||||
if (elem?.tagName != 'STYLE') {
|
||||
if (elem?.tagName === 'STYLE') continue;
|
||||
Object.entries(this.pageStyles).forEach(item => {
|
||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||
// Remove the style or skip
|
||||
this.renderer.removeStyle(elem, item[0]);
|
||||
return;
|
||||
}
|
||||
this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -895,7 +926,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
setOverrideStyles() {
|
||||
const bodyNode = document.querySelector('body');
|
||||
const bodyNode = this.document.querySelector('body');
|
||||
if (bodyNode !== undefined && bodyNode !== null) {
|
||||
if (this.user.preferences.siteDarkMode) {
|
||||
bodyNode.classList.remove('bg-dark');
|
||||
|
|
@ -904,7 +935,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
bodyNode.style.background = this.getDarkModeBackgroundColor();
|
||||
}
|
||||
this.backgroundColor = this.getDarkModeBackgroundColor();
|
||||
const head = document.querySelector('head');
|
||||
const head = this.document.querySelector('head');
|
||||
if (this.darkMode) {
|
||||
this.renderer.appendChild(head, this.darkModeStyleElem)
|
||||
} else {
|
||||
|
|
@ -940,12 +971,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
// Part selector is a XPATH
|
||||
element = this.getElementFromXPath(partSelector);
|
||||
} else {
|
||||
element = document.querySelector('*[id="' + partSelector + '"]');
|
||||
element = this.document.querySelector('*[id="' + partSelector + '"]');
|
||||
}
|
||||
|
||||
if (element === null) return;
|
||||
|
||||
this.scrollService.scrollTo(element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET);
|
||||
this.scrollService.scrollTo(element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET, this.reader.nativeElement);
|
||||
}
|
||||
|
||||
toggleClickToPaginate() {
|
||||
|
|
@ -976,7 +1007,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
getElementFromXPath(path: string) {
|
||||
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||
return node as Element;
|
||||
}
|
||||
|
|
@ -986,7 +1017,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
getXPathTo(element: any): string {
|
||||
if (element === null) return '';
|
||||
if (element.id !== '') { return 'id("' + element.id + '")'; }
|
||||
if (element === document.body) { return element.tagName; }
|
||||
if (element === this.document.body) { return element.tagName; }
|
||||
|
||||
|
||||
let ix = 0;
|
||||
|
|
@ -1014,4 +1045,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
this.isFullscreen = this.readerService.checkFullscreenMode();
|
||||
if (this.isFullscreen) {
|
||||
this.readerService.exitFullscreen(() => {
|
||||
this.isFullscreen = false;
|
||||
this.renderer.removeStyle(this.reader.nativeElement, 'background');
|
||||
});
|
||||
} else {
|
||||
this.readerService.enterFullscreen(this.reader.nativeElement, () => {
|
||||
this.isFullscreen = true;
|
||||
// HACK: This is a bug with how browsers change the background color for fullscreen mode
|
||||
if (!this.darkMode) {
|
||||
this.renderer.setStyle(this.reader.nativeElement, 'background', 'white');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<ul class="list-unstyled">
|
||||
<li class="list-group-item" *ngIf="bookmarks.length > 0">
|
||||
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="bookmarks.length === 0">
|
||||
No bookmarks yet
|
||||
</li>
|
||||
</ul>
|
||||
<p *ngIf="bookmarks.length > 0; else noBookmarks">
|
||||
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
|
||||
</p>
|
||||
<ng-template #noBookmarks>No bookmarks yet</ng-template>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div *ngFor="let bookmark of bookmarks; let idx = index">
|
||||
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ export class BookmarksModalComponent implements OnInit {
|
|||
this.modal.close();
|
||||
}
|
||||
|
||||
removeBookmark(bookmark: PageBookmark, index: number) {
|
||||
this.bookmarks.splice(index, 1);
|
||||
}
|
||||
|
||||
downloadBookmarks() {
|
||||
this.isDownloading = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks).pipe(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@
|
|||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<div class="modal-body scrollable-modal" *ngIf="utilityService.isChapter(data)">
|
||||
<ng-container *ngIf="utilityService.isChapter(data)">
|
||||
<app-chapter-metadata-detail [chapter]="data"></app-chapter-metadata-detail>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal" *ngIf="utilityService.isVolume(data)">
|
||||
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
|
||||
|
||||
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ export class CardDetailsModalComponent implements OnInit {
|
|||
case(Action.MarkAsUnread):
|
||||
this.markChapterAsUnread(chapter);
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addChapterToReadingList(chapter, this.seriesId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
|
||||
this.editSeriesForm = this.fb.group({
|
||||
id: new FormControl(this.series.id, []),
|
||||
summary: new FormControl(this.series.summary, []),
|
||||
summary: new FormControl('', []),
|
||||
name: new FormControl(this.series.name, []),
|
||||
localizedName: new FormControl(this.series.localizedName, []),
|
||||
sortName: new FormControl(this.series.sortName, []),
|
||||
|
|
@ -86,8 +86,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
|
||||
if (metadata) {
|
||||
this.metadata = metadata;
|
||||
this.settings.savedData = metadata.tags;
|
||||
this.tags = metadata.tags;
|
||||
this.settings.savedData = metadata.collectionTags;
|
||||
this.tags = metadata.collectionTags;
|
||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
28
UI/Web/src/app/cards/bookmark/bookmark.component.html
Normal file
28
UI/Web/src/app/cards/bookmark/bookmark.component.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<div class="card" *ngIf="bookmark != undefined">
|
||||
<img class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="170px">
|
||||
|
||||
<div class="card-body" *ngIf="bookmark.page >= 0">
|
||||
<div class="header-row">
|
||||
<span class="card-title" tabindex="0">
|
||||
Page {{bookmark.page + 1}}
|
||||
</span>
|
||||
<span class="card-actions float-right" *ngIf="series != undefined">
|
||||
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
|
||||
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
|
||||
<ng-container *ngIf="isClearing; else notClearing">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ng-container>
|
||||
<ng-template #notClearing>
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
|
||||
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
UI/Web/src/app/cards/bookmark/bookmark.component.scss
Normal file
25
UI/Web/src/app/cards/bookmark/bookmark.component.scss
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.card-body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-overflow {
|
||||
font-size: 13px;
|
||||
width: 130px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
43
UI/Web/src/app/cards/bookmark/bookmark.component.ts
Normal file
43
UI/Web/src/app/cards/bookmark/bookmark.component.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { PageBookmark } from '../../_models/page-bookmark';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark',
|
||||
templateUrl: './bookmark.component.html',
|
||||
styleUrls: ['./bookmark.component.scss']
|
||||
})
|
||||
export class BookmarkComponent implements OnInit {
|
||||
|
||||
@Input() bookmark: PageBookmark | undefined;
|
||||
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
|
||||
series: Series | undefined;
|
||||
|
||||
isClearing: boolean = false;
|
||||
isDownloading: boolean = false;
|
||||
|
||||
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.bookmark) {
|
||||
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
|
||||
this.series = series;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleClick(event: any) {
|
||||
|
||||
}
|
||||
|
||||
removeBookmark() {
|
||||
if (this.bookmark === undefined) return;
|
||||
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
|
||||
this.bookmarkRemoved.emit(this.bookmark);
|
||||
this.bookmark = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -10,24 +10,350 @@
|
|||
</h2>
|
||||
</div>
|
||||
|
||||
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="sr-only">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row no-gutters filter-section" #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<div class="col">
|
||||
<form class="ml-2" [formGroup]="filterForm">
|
||||
<div class="form-group" *ngIf="filters.length > 0">
|
||||
<label for="series-filter">Filter</label>
|
||||
<select class="form-control" id="series-filter" formControlName="filter" (ngModelChange)="handleFilterChange($event)" style="max-width: 200px;">
|
||||
<option [value]="i" *ngFor="let opt of filters; let i = index">{{opt.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="not-phone-hidden">
|
||||
<app-drawer #commentDrawer="drawer" [isOpen]="!filteringCollapsed" [style.--drawer-width]="'300px'" [style.--drawer-background-color]="'#010409'" (drawerClosed)="filteringCollapsed = !filteringCollapsed">
|
||||
<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">×</span>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<ng-template #filterSection>
|
||||
<ng-template #globalFilterTooltip>This is library agnostic</ng-template>
|
||||
<div class="filter-section mx-auto pb-3">
|
||||
<div class="row justify-content-center no-gutters">
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.formatDisabled">
|
||||
<div class="form-group">
|
||||
<label for="format">Format</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="sr-only" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3"*ngIf="!filterSettings.libraryDisabled">
|
||||
<div class="form-group">
|
||||
<label for="libraries">Libraries</label>
|
||||
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.collectionDisabled">
|
||||
<div class="form-group">
|
||||
<label for="collections">Collections</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
|
||||
<span class="sr-only" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
|
||||
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3" *ngIf="!filterSettings.genresDisabled">
|
||||
<div class="form-group">
|
||||
<label for="genres">Genres</label>
|
||||
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3" *ngIf="!filterSettings.tagsDisabled">
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags</label>
|
||||
<app-typeahead (selectedData)="updateTagFilters($event)" [settings]="tagsSettings" [reset]="resetTypeaheads">
|
||||
<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 justify-content-center no-gutters">
|
||||
<!-- The People row -->
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.CoverArtist)">
|
||||
<div class="form-group">
|
||||
<label for="cover-artist">Cover Artists</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Writer)">
|
||||
<div class="form-group">
|
||||
<label for="writers">Writers</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Publisher)">
|
||||
<div class="form-group">
|
||||
<label for="publisher">Publisher</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Penciller)">
|
||||
<div class="form-group">
|
||||
<label for="penciller">Penciller</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Letterer)">
|
||||
<div class="form-group">
|
||||
<label for="letterer">Letterer</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Inker)">
|
||||
<div class="form-group">
|
||||
<label for="inker">Inker</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Editor)">
|
||||
<div class="form-group">
|
||||
<label for="editor">Editor</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Colorist)">
|
||||
<div class="form-group">
|
||||
<label for="colorist">Colorist</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Character)">
|
||||
<div class="form-group">
|
||||
<label for="character">Character</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="peopleSettings.hasOwnProperty(PersonRole.Translator)">
|
||||
<div class="form-group">
|
||||
<label for="translators">Translators</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)" [reset]="resetTypeaheads">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center no-gutters">
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.readProgressDisabled">
|
||||
<label>Read Progress</label>
|
||||
<form [formGroup]="readProgressGroup">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="notread" formControlName="notRead">
|
||||
<label class="form-check-label" for="notread">Unread</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress">
|
||||
<label class="form-check-label" for="inprogress">In Progress</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="read" formControlName="read">
|
||||
<label class="form-check-label" for="read">Read</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ratingDisabled">
|
||||
<label for="ratings">Rating</label>
|
||||
<form class="form-inline">
|
||||
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
|
||||
<ng-template let-fill="fill" let-index="index">
|
||||
<span class="star" [class.filled]="(index >= (filter.rating - 1)) && filter.rating > 0" [ngbTooltip]="(index + 1) + ' and up'">★</span>
|
||||
</ng-template>
|
||||
</ngb-rating>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.ageRatingDisabled">
|
||||
<label for="age-rating">Age Rating</label>
|
||||
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3" *ngIf="!filterSettings.languageDisabled">
|
||||
<label for="languages">Language</label>
|
||||
<app-typeahead (selectedData)="updateLanguageRating($event)" [settings]="languageSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3" *ngIf="!filterSettings.publicationStatusDisabled">
|
||||
<label for="publication-status">Publication Status</label>
|
||||
<app-typeahead (selectedData)="updatePublicationStatus($event)" [settings]="publicationStatusSettings" [reset]="resetTypeaheads">
|
||||
<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 class="col-md-2 mr-3"></div>
|
||||
</div>
|
||||
<div class="row justify-content-center no-gutters">
|
||||
<div class="col-md-2 mr-3" *ngIf="!filterSettings.sortDisabled">
|
||||
<form [formGroup]="sortGroup">
|
||||
<div class="form-group">
|
||||
<label for="sort-options">Sort By</label>
|
||||
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
|
||||
<i class="fa fa-arrow-up" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
|
||||
<ng-template #descSort>
|
||||
<i class="fa fa-arrow-down" title="Descending"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<select id="sort-options" class="form-control" formControlName="sortField" style="height: 38px;">
|
||||
<option [value]="SortField.SortName">Sort Name</option>
|
||||
<option [value]="SortField.Created">Created</option>
|
||||
<option [value]="SortField.LastModified">Last Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 mr-3" *ngIf="filterSettings.sortDisabled"></div>
|
||||
<div class="col-md-2 mr-3"></div>
|
||||
<div class="col-md-2 mr-3"></div>
|
||||
<div class="col-md-2 mr-3 mt-4">
|
||||
<button class="btn btn-secondary btn-block" (click)="clear()">Clear</button>
|
||||
</div>
|
||||
<div class="col-md-2 mr-3 mt-4">
|
||||
<button class="btn btn-primary btn-block" (click)="apply()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
@use '../../../theme/colors';
|
||||
|
||||
.star {
|
||||
font-size: 1.5rem;
|
||||
color: colors.$rating-empty;
|
||||
}
|
||||
.filled {
|
||||
color: colors.$rating-filled;
|
||||
}
|
||||
|
|
@ -1,39 +1,57 @@
|
|||
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { FormGroup, FormControl } from '@angular/forms';
|
||||
import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Genre } from 'src/app/_models/genre';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||
import { Language } from 'src/app/_models/metadata/language';
|
||||
import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { FilterItem } from 'src/app/_models/series-filter';
|
||||
import { Person, PersonRole } from 'src/app/_models/person';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
|
||||
import { Tag } from 'src/app/_models/tag';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
|
||||
export enum FilterAction {
|
||||
/**
|
||||
* If an option is selected on a multi select component
|
||||
*/
|
||||
Added = 0,
|
||||
/**
|
||||
* If an option is unselected on a multi select component
|
||||
*/
|
||||
Removed = 1,
|
||||
/**
|
||||
* If an option is selected on a single select component
|
||||
*/
|
||||
Selected = 2
|
||||
}
|
||||
|
||||
export interface UpdateFilterEvent {
|
||||
filterItem: FilterItem;
|
||||
action: FilterAction;
|
||||
}
|
||||
|
||||
const ANIMATION_SPEED = 300;
|
||||
|
||||
export class FilterSettings {
|
||||
libraryDisabled = false;
|
||||
formatDisabled = false;
|
||||
collectionDisabled = false;
|
||||
genresDisabled = false;
|
||||
peopleDisabled = false;
|
||||
readProgressDisabled = false;
|
||||
ratingDisabled = false;
|
||||
sortDisabled = false;
|
||||
ageRatingDisabled = false;
|
||||
tagsDisabled = false;
|
||||
languageDisabled = false;
|
||||
publicationStatusDisabled = false;
|
||||
presets: SeriesFilter | undefined;
|
||||
/**
|
||||
* Should the filter section be open by default
|
||||
*/
|
||||
openByDefault = false;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss']
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit {
|
||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() header: string = '';
|
||||
@Input() isLoading: boolean = false;
|
||||
|
|
@ -43,32 +61,382 @@ export class CardDetailLayoutComponent implements OnInit {
|
|||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
/**
|
||||
* A list of Filters which can filter the data of the page. If nothing is passed, the control will not show.
|
||||
*/
|
||||
@Input() filters: Array<FilterItem> = [];
|
||||
@Input() trackByIdentity!: (index: number, item: any) => string;
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<SeriesFilter> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
filterForm: FormGroup = new FormGroup({
|
||||
filter: new FormControl(0, []),
|
||||
});
|
||||
|
||||
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
|
||||
librarySettings: TypeaheadSettings<Library> = new TypeaheadSettings();
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
collectionSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
||||
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
resetTypeaheads: Subject<boolean> = new ReplaySubject(1);
|
||||
|
||||
/**
|
||||
* Controls the visiblity of extended controls that sit below the main header.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
|
||||
constructor() { }
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
|
||||
readProgressGroup!: FormGroup;
|
||||
sortGroup!: FormGroup;
|
||||
isAscendingSort: boolean = true;
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
return PersonRole;
|
||||
}
|
||||
|
||||
get SortField(): typeof SortField {
|
||||
return SortField;
|
||||
}
|
||||
|
||||
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService,
|
||||
private utilityService: UtilityService, private collectionTagService: CollectionTagService) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup = new FormGroup({
|
||||
read: new FormControl(this.filter.readStatus.read, []),
|
||||
notRead: new FormControl(this.filter.readStatus.notRead, []),
|
||||
inProgress: new FormControl(this.filter.readStatus.inProgress, []),
|
||||
});
|
||||
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []),
|
||||
});
|
||||
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||
|
||||
let sum = 0;
|
||||
sum += (this.filter.readStatus.read ? 1 : 0);
|
||||
sum += (this.filter.readStatus.inProgress ? 1 : 0);
|
||||
sum += (this.filter.readStatus.notRead ? 1 : 0);
|
||||
|
||||
if (sum === 1) {
|
||||
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
if (this.filter.sortOptions == null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
|
||||
};
|
||||
}
|
||||
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.filterForm.get('filter')?.value}_${item.id}_${index}`;
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}`;
|
||||
|
||||
if (this.filterSettings === undefined) {
|
||||
this.filterSettings = new FilterSettings();
|
||||
}
|
||||
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
setupTypeaheads() {
|
||||
|
||||
this.setupFormatTypeahead();
|
||||
|
||||
forkJoin([
|
||||
this.setupLibraryTypeahead(),
|
||||
this.setupCollectionTagTypeahead(),
|
||||
this.setupAgeRatingSettings(),
|
||||
this.setupPublicationStatusSettings(),
|
||||
this.setupTagSettings(),
|
||||
this.setupLanguageSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
]).subscribe(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
if (this.filterSettings.openByDefault) {
|
||||
this.filteringCollapsed = false;
|
||||
}
|
||||
this.apply();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setupFormatTypeahead() {
|
||||
this.formatSettings.minCharacters = 0;
|
||||
this.formatSettings.multiple = true;
|
||||
this.formatSettings.id = 'format';
|
||||
this.formatSettings.unique = true;
|
||||
this.formatSettings.addIfNonExisting = false;
|
||||
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters);
|
||||
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) {
|
||||
this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value));
|
||||
this.filter.formats = this.formatSettings.savedData.map(item => item.value);
|
||||
this.resetTypeaheads.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupLibraryTypeahead() {
|
||||
this.librarySettings.minCharacters = 0;
|
||||
this.librarySettings.multiple = true;
|
||||
this.librarySettings.id = 'libraries';
|
||||
this.librarySettings.unique = true;
|
||||
this.librarySettings.addIfNonExisting = false;
|
||||
this.librarySettings.fetchFn = (filter: string) => {
|
||||
return this.libraryService.getLibrariesForMember();
|
||||
};
|
||||
this.librarySettings.compareFn = (options: Library[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.name.toLowerCase() === f);
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) {
|
||||
return this.librarySettings.fetchFn('').pipe(map(libraries => {
|
||||
this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id));
|
||||
this.filter.libraries = this.librarySettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupGenreTypeahead() {
|
||||
this.genreSettings.minCharacters = 0;
|
||||
this.genreSettings.multiple = true;
|
||||
this.genreSettings.id = 'genres';
|
||||
this.genreSettings.unique = true;
|
||||
this.genreSettings.addIfNonExisting = false;
|
||||
this.genreSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllGenres(this.filter.libraries);
|
||||
};
|
||||
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) {
|
||||
return this.genreSettings.fetchFn('').pipe(map(genres => {
|
||||
this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id));
|
||||
this.filter.genres = this.genreSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupAgeRatingSettings() {
|
||||
this.ageRatingSettings.minCharacters = 0;
|
||||
this.ageRatingSettings.multiple = true;
|
||||
this.ageRatingSettings.id = 'age-rating';
|
||||
this.ageRatingSettings.unique = true;
|
||||
this.ageRatingSettings.addIfNonExisting = false;
|
||||
this.ageRatingSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllAgeRatings(this.filter.libraries);
|
||||
};
|
||||
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) {
|
||||
return this.ageRatingSettings.fetchFn('').pipe(map(rating => {
|
||||
this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value));
|
||||
this.filter.ageRating = this.ageRatingSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupPublicationStatusSettings() {
|
||||
this.publicationStatusSettings.minCharacters = 0;
|
||||
this.publicationStatusSettings.multiple = true;
|
||||
this.publicationStatusSettings.id = 'publication-status';
|
||||
this.publicationStatusSettings.unique = true;
|
||||
this.publicationStatusSettings.addIfNonExisting = false;
|
||||
this.publicationStatusSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllPublicationStatus(this.filter.libraries);
|
||||
};
|
||||
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) {
|
||||
return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => {
|
||||
this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value));
|
||||
this.filter.publicationStatus = this.publicationStatusSettings.savedData.map(item => item.value);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupTagSettings() {
|
||||
this.tagsSettings.minCharacters = 0;
|
||||
this.tagsSettings.multiple = true;
|
||||
this.tagsSettings.id = 'tags';
|
||||
this.tagsSettings.unique = true;
|
||||
this.tagsSettings.addIfNonExisting = false;
|
||||
this.tagsSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllTags(this.filter.libraries);
|
||||
};
|
||||
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) {
|
||||
return this.tagsSettings.fetchFn('').pipe(map(tags => {
|
||||
this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id));
|
||||
this.filter.tags = this.tagsSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupLanguageSettings() {
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = true;
|
||||
this.languageSettings.id = 'languages';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllLanguages(this.filter.libraries);
|
||||
};
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) {
|
||||
return this.languageSettings.fetchFn('').pipe(map(languages => {
|
||||
this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode));
|
||||
this.filter.languages = this.languageSettings.savedData.map(item => item.isoCode);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupCollectionTagTypeahead() {
|
||||
this.collectionSettings.minCharacters = 0;
|
||||
this.collectionSettings.multiple = true;
|
||||
this.collectionSettings.id = 'collections';
|
||||
this.collectionSettings.unique = true;
|
||||
this.collectionSettings.addIfNonExisting = false;
|
||||
this.collectionSettings.fetchFn = (filter: string) => {
|
||||
return this.collectionTagService.allTags();
|
||||
};
|
||||
this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
}
|
||||
|
||||
if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) {
|
||||
return this.collectionSettings.fetchFn('').pipe(map(tags => {
|
||||
this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id));
|
||||
this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.id);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
updateFromPreset(id: string, peopleFilterField: Array<any>, presetField: Array<any> | undefined, role: PersonRole) {
|
||||
const personSettings = this.createBlankPersonSettings(id, role)
|
||||
if (presetField && presetField.length > 0) {
|
||||
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
|
||||
return fetch('').pipe(map(people => {
|
||||
personSettings.savedData = people.filter(item => presetField.includes(item.id));
|
||||
peopleFilterField = personSettings.savedData.map(item => item.id);
|
||||
this.resetTypeaheads.next(true);
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.updatePersonFilters(personSettings.savedData as Person[], role);
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
this.peopleSettings = {};
|
||||
|
||||
return forkJoin([
|
||||
this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer),
|
||||
this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character),
|
||||
this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist),
|
||||
this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist),
|
||||
this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor),
|
||||
this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker),
|
||||
this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer),
|
||||
this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller),
|
||||
this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher),
|
||||
this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator)
|
||||
]).pipe(map(results => {
|
||||
this.resetTypeaheads.next(true);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
|
||||
fetchPeople(role: PersonRole, filter: string) {
|
||||
return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => {
|
||||
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
|
||||
}));
|
||||
}
|
||||
|
||||
createBlankPersonSettings(id: string, role: PersonRole) {
|
||||
var personSettings = new TypeaheadSettings<Person>();
|
||||
personSettings.minCharacters = 0;
|
||||
personSettings.multiple = true;
|
||||
personSettings.unique = true;
|
||||
personSettings.addIfNonExisting = false;
|
||||
personSettings.id = id;
|
||||
personSettings.compareFn = (options: Person[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.name.toLowerCase() === f);
|
||||
}
|
||||
personSettings.fetchFn = (filter: string) => {
|
||||
return this.fetchPeople(role, filter);
|
||||
};
|
||||
return personSettings;
|
||||
}
|
||||
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
}
|
||||
|
|
@ -88,11 +456,118 @@ export class CardDetailLayoutComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
handleFilterChange(index: string) {
|
||||
this.applyFilter.emit({
|
||||
filterItem: this.filters[parseInt(index, 10)],
|
||||
action: FilterAction.Selected
|
||||
});
|
||||
|
||||
updateFormatFilters(formats: MangaFormat[]) {
|
||||
this.filter.formats = formats.map(item => item) || [];
|
||||
}
|
||||
|
||||
updateLibraryFilters(libraries: Library[]) {
|
||||
this.filter.libraries = libraries.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateGenreFilters(genres: Genre[]) {
|
||||
this.filter.genres = genres.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateTagFilters(tags: Tag[]) {
|
||||
this.filter.tags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updatePersonFilters(persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.CoverArtist:
|
||||
this.filter.coverArtist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.filter.character = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.filter.colorist = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.filter.editor = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.filter.inker = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.filter.letterer = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.filter.penciller = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.filter.publisher = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
this.filter.writers = persons.map(p => p.id);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.filter.translators = persons.map(p => p.id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updateCollectionFilters(tags: CollectionTag[]) {
|
||||
this.filter.collectionTags = tags.map(item => item.id) || [];
|
||||
}
|
||||
|
||||
updateRating(rating: any) {
|
||||
this.filter.rating = rating;
|
||||
}
|
||||
|
||||
updateAgeRating(ratingDtos: AgeRatingDto[]) {
|
||||
this.filter.ageRating = ratingDtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updatePublicationStatus(dtos: PublicationStatusDto[]) {
|
||||
this.filter.publicationStatus = dtos.map(item => item.value) || [];
|
||||
}
|
||||
|
||||
updateLanguageRating(languages: Language[]) {
|
||||
this.filter.languages = languages.map(item => item.isoCode) || [];
|
||||
}
|
||||
|
||||
updateReadStatus(status: string) {
|
||||
if (status === 'read') {
|
||||
this.filter.readStatus.read = !this.filter.readStatus.read;
|
||||
} else if (status === 'inProgress') {
|
||||
this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress;
|
||||
} else if (status === 'notRead') {
|
||||
this.filter.readStatus.notRead = !this.filter.readStatus.notRead;
|
||||
}
|
||||
}
|
||||
|
||||
updateSortOrder() {
|
||||
this.isAscendingSort = !this.isAscendingSort;
|
||||
if (this.filter.sortOptions === null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: SortField.SortName
|
||||
}
|
||||
}
|
||||
|
||||
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup.get('read')?.setValue(true);
|
||||
this.readProgressGroup.get('notRead')?.setValue(true);
|
||||
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||
this.isAscendingSort = true;
|
||||
// Apply any presets which will trigger the apply
|
||||
this.setupTypeaheads();
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.applyFilter.emit(this.filter);
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="card">
|
||||
<div class="card {{selected ? 'selected-highlight' : ''}}">
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
<img *ngIf="total > 0 || supressArchiveWarning" class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
|
||||
|
|
@ -20,13 +20,13 @@
|
|||
|
||||
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
|
||||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
|
||||
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
<div>
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
|
||||
<span *ngIf="isPromoted()">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
<span class="sr-only">(promoted)</span>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
@import '../../../theme/colors';
|
||||
@use '../../../theme/colors';
|
||||
|
||||
$triangle-size: 40px;
|
||||
$triangle-size: 30px;
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
width: 160px;
|
||||
height: 18px;
|
||||
background-color: $error-color;
|
||||
background-color: colors.$error-color;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -38,6 +38,9 @@ $image-width: 160px;
|
|||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.selected-highlight {
|
||||
outline: 2px solid colors.$primary-color;
|
||||
}
|
||||
|
||||
|
||||
.img-top {
|
||||
|
|
@ -49,7 +52,7 @@ $image-width: 160px;
|
|||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: $primary-color;
|
||||
color: colors.$primary-color;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +73,7 @@ $image-width: 160px;
|
|||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 $triangle-size $triangle-size 0;
|
||||
border-color: transparent $primary-color transparent transparent;
|
||||
border-color: transparent colors.$primary-color transparent transparent;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
|
|||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
|
@ -49,7 +50,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
/**
|
||||
* This is the entity we are representing. It will be returned if an action is executed.
|
||||
*/
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag;
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
|
|
@ -79,12 +80,18 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
* Format of the entity (only applies to Series)
|
||||
*/
|
||||
format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
chapterTitle: string = '';
|
||||
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
isShiftDown: boolean = false;
|
||||
|
||||
get tooltipTitle() {
|
||||
if (this.chapterTitle === '' || this.chapterTitle === null) return this.title;
|
||||
return this.chapterTitle;
|
||||
}
|
||||
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
|
|
@ -111,6 +118,15 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
this.format = (this.entity as Series).format;
|
||||
|
||||
if (this.utilityService.isChapter(this.entity)) {
|
||||
this.chapterTitle = this.utilityService.asChapter(this.entity).titleName;
|
||||
} else if (this.utilityService.isVolume(this.entity)) {
|
||||
const vol = this.utilityService.asVolume(this.entity);
|
||||
if (vol.chapters !== undefined && vol.chapters.length > 0) {
|
||||
this.chapterTitle = vol.chapters[0].titleName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit
|
|||
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
|
||||
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { LazyLoadImageModule } from 'ng-lazyload-image';
|
||||
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgxFileDropModule } from 'ngx-file-drop';
|
||||
|
|
@ -21,6 +21,9 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det
|
|||
import { BulkOperationsComponent } from './bulk-operations/bulk-operations.component';
|
||||
import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
|
||||
import { FileInfoComponent } from './file-info/file-info.component';
|
||||
import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
|
||||
|
||||
|
||||
|
|
@ -38,7 +41,10 @@ import { PipeModule } from '../pipe/pipe.module';
|
|||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent,
|
||||
BulkOperationsComponent,
|
||||
BulkAddToCollectionComponent
|
||||
BulkAddToCollectionComponent,
|
||||
ChapterMetadataDetailComponent,
|
||||
FileInfoComponent,
|
||||
BookmarkComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
|
@ -52,6 +58,7 @@ import { PipeModule } from '../pipe/pipe.module';
|
|||
NgbNavModule,
|
||||
NgbTooltipModule, // Card item
|
||||
NgbCollapseModule,
|
||||
NgbRatingModule,
|
||||
|
||||
NgbNavModule, //Series Detail
|
||||
LazyLoadImageModule,
|
||||
|
|
@ -75,7 +82,8 @@ import { PipeModule } from '../pipe/pipe.module';
|
|||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent,
|
||||
BulkOperationsComponent
|
||||
BulkOperationsComponent,
|
||||
ChapterMetadataDetailComponent
|
||||
]
|
||||
})
|
||||
export class CardsModule { }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
<ng-container *ngIf="chapter !== undefined">
|
||||
<div class="container-fluid">
|
||||
<!-- <h4>{{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}} <span title="Id">({{chapter.id}})</span></h4> -->
|
||||
|
||||
|
||||
<!-- Arc Information -->
|
||||
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Title: {{chapter.titleName || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
|
||||
Added: {{(chapter.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Release Date: {{(chapter.releaseDate | date: 'shortDate') || '-'}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled" >
|
||||
<li class="media my-4">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<img class="mr-3" style="width: 74px" [src]="chapter.coverImage">
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<!-- TODO: Add back in
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span> -->
|
||||
<span class="badge badge-primary badge-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>Files</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group file-list">
|
||||
<app-file-info *ngFor="let file of chapter.files" [file]="file" [created]="chapter.created"></app-file-info>
|
||||
</ul>
|
||||
|
||||
|
||||
<ng-container>
|
||||
<div class="row no-gutters mt-1" *ngIf="chapter.writers && chapter.writers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Writers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.writers" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="chapter.coverArtist && chapter.coverArtist.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Artists</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.coverArtist" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="chapter.publisher && chapter.publisher.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Publishers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.publisher" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<!--
|
||||
|
||||
<div class="container-fluid" *ngIf="metadata !== undefined">
|
||||
Chapter {{chapter.range}} {{metadata.title.length > 0 ? ' - ' + metadata.title : ''}}
|
||||
Title: {{metadata.title || '-'}}
|
||||
Year: {{metadata.year || '-'}}
|
||||
Arc Information
|
||||
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Id: {{chapter.id}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
|
||||
Added: {{(chapter.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-metadata-detail',
|
||||
templateUrl: './chapter-metadata-detail.component.html',
|
||||
styleUrls: ['./chapter-metadata-detail.component.scss']
|
||||
})
|
||||
export class ChapterMetadataDetailComponent implements OnInit {
|
||||
|
||||
@Input() chapter!: Chapter;
|
||||
@Input() libraryType: LibraryType = LibraryType.Manga;
|
||||
//metadata!: ChapterMetadata;
|
||||
|
||||
get LibraryType(): typeof LibraryType {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
constructor(private metadataService: MetadataService, public utilityService: UtilityService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.metadataService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
// console.log('Chapter ', this.chapter.number, ' metadata: ', metadata);
|
||||
// this.metadata = metadata;
|
||||
// })
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Chapter>, chapter: Chapter) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, chapter);
|
||||
}
|
||||
}
|
||||
|
||||
readChapter(chapter: Chapter) {
|
||||
// if (chapter.pages === 0) {
|
||||
// this.toastr.error('There are no pages. Kavita was not able to read this archive.');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
|
||||
// this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]);
|
||||
// } else {
|
||||
// this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
11
UI/Web/src/app/cards/file-info/file-info.component.html
Normal file
11
UI/Web/src/app/cards/file-info/file-info.component.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<li class="list-group-item">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col" *ngIf="created != undefined">
|
||||
Added: {{(created | date: 'short') || '-'}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
0
UI/Web/src/app/cards/file-info/file-info.component.scss
Normal file
0
UI/Web/src/app/cards/file-info/file-info.component.scss
Normal file
25
UI/Web/src/app/cards/file-info/file-info.component.ts
Normal file
25
UI/Web/src/app/cards/file-info/file-info.component.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { MangaFile } from 'src/app/_models/manga-file';
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-info',
|
||||
templateUrl: './file-info.component.html',
|
||||
styleUrls: ['./file-info.component.scss']
|
||||
})
|
||||
export class FileInfoComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* MangaFile to display
|
||||
*/
|
||||
@Input() file!: MangaFile;
|
||||
/**
|
||||
* DateTime the entity this file belongs to was created
|
||||
*/
|
||||
@Input() created: string | undefined = undefined;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -109,6 +109,9 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
|||
case(Action.AddToReadingList):
|
||||
this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ });
|
||||
break;
|
||||
case(Action.AddToCollection):
|
||||
this.actionService.addMultipleSeriesToCollectionTag([series], () => {/* No Operation */ });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -132,35 +135,26 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
refreshMetdata(series: Series) {
|
||||
this.seriesService.refreshMetadata(series).subscribe((res: any) => {
|
||||
this.toastr.success('Refresh started for ' + series.name);
|
||||
});
|
||||
async refreshMetdata(series: Series) {
|
||||
this.actionService.refreshMetdata(series);
|
||||
}
|
||||
|
||||
scanLibrary(series: Series) {
|
||||
async scanLibrary(series: Series) {
|
||||
this.seriesService.scan(series.libraryId, series.id).subscribe((res: any) => {
|
||||
this.toastr.success('Scan started for ' + series.name);
|
||||
this.toastr.success('Scan queued for ' + series.name);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSeries(series: Series) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||
if (res) {
|
||||
this.toastr.success('Series deleted');
|
||||
this.actionService.deleteSeries(series, (result: boolean) => {
|
||||
if (result) {
|
||||
this.reload.emit(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markAsUnread(series: Series) {
|
||||
this.seriesService.markUnread(series.id).subscribe(res => {
|
||||
this.toastr.success(series.name + ' is now unread');
|
||||
series.pagesRead = 0;
|
||||
this.actionService.markSeriesAsUnread(series, () => {
|
||||
if (this.data) {
|
||||
this.data.pagesRead = 0;
|
||||
}
|
||||
|
|
@ -170,9 +164,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
markAsRead(series: Series) {
|
||||
this.seriesService.markRead(series.id).subscribe(res => {
|
||||
this.toastr.success(series.name + ' is now read');
|
||||
series.pagesRead = series.pages;
|
||||
this.actionService.markSeriesAsRead(series, () => {
|
||||
if (this.data) {
|
||||
this.data.pagesRead = series.pages;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@
|
|||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="seriesPagination"
|
||||
[filterSettings]="filterSettings"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[filters]="filters"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '~bootstrap/scss/mixins/_breakpoints.scss';
|
||||
@import '~bootstrap/scss/mixins/breakpoints';
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
|
||||
import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
|
|
@ -39,10 +38,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
seriesPagination!: Pagination;
|
||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||
isAdmin: boolean = false;
|
||||
filters: Array<FilterItem> = mangaFormatFilters;
|
||||
filter: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
};
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
||||
private onDestory: Subject<void> = new Subject<void>();
|
||||
|
||||
|
|
@ -85,7 +82,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
|
||||
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
|
||||
private modalService: NgbModal, private titleService: Title, private accountService: AccountService,
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService) {
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
|
||||
private utilityService: UtilityService) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
|
|
@ -100,6 +98,10 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
return;
|
||||
}
|
||||
const tagId = parseInt(routeId, 10);
|
||||
|
||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter());
|
||||
this.filterSettings.presets.collectionTags = [tagId];
|
||||
|
||||
this.updateTag(tagId);
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +151,6 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
this.collectionTag = matchingTags[0];
|
||||
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id));
|
||||
this.titleService.setTitle('Kavita - ' + this.collectionTag.title + ' Collection');
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -174,8 +175,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
updateFilter(data: UpdateFilterEvent) {
|
||||
this.filter.mangaFormat = data.filterItem.value;
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.seriesPagination !== undefined && this.seriesPagination !== null) {
|
||||
this.seriesPagination.currentPage = 1;
|
||||
this.onPageChange(this.seriesPagination);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout header="{{libraryName}}"
|
||||
<app-card-detail-layout [header]="libraryName"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[actions]="actions"
|
||||
[pagination]="pagination"
|
||||
[filters]="filters"
|
||||
[filterSettings]="filterSettings"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
|
|
@ -30,11 +30,9 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
loadingSeries = false;
|
||||
pagination!: Pagination;
|
||||
actions: ActionItem<Library>[] = [];
|
||||
filters: Array<FilterItem> = mangaFormatFilters;
|
||||
filter: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
};
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
|
@ -75,12 +73,14 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService) {
|
||||
const routeId = this.route.snapshot.paramMap.get('id');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.libraryId = parseInt(routeId, 10);
|
||||
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => {
|
||||
|
|
@ -89,7 +89,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
this.loadPage();
|
||||
|
||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter());
|
||||
this.filterSettings.presets.libraries = [this.libraryId];
|
||||
|
||||
//this.loadPage();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
@ -134,8 +138,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
updateFilter(data: UpdateFilterEvent) {
|
||||
this.filter.mangaFormat = data.filterItem.value;
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
|
|
@ -151,7 +155,13 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
this.loadingSeries = true;
|
||||
|
||||
this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
|
||||
if (this.filter == undefined) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.filter.libraries.push(this.libraryId);
|
||||
}
|
||||
|
||||
this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
this.pagination = series.pagination;
|
||||
this.loadingSeries = false;
|
||||
|
|
@ -160,7 +170,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
onPageChange(pagination: Pagination) {
|
||||
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage);
|
||||
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?' + 'page=' + this.pagination.currentPage);
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<app-carousel-reel [items]="libraries" title="Libraries">
|
||||
<app-carousel-reel [items]="libraries" title="Libraries" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-library-card [data]="item"></app-library-card>
|
||||
</ng-template>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||
this.router.navigate(['recently-added']);
|
||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||
this.router.navigate(['on-deck']);
|
||||
} else if (sectionTitle.toLowerCase() === 'libraries') {
|
||||
this.router.navigate(['all-series']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,3 @@ export enum PAGING_DIRECTION {
|
|||
BACKWARDS = -1,
|
||||
}
|
||||
|
||||
export enum COLOR_FILTER {
|
||||
NONE = '',
|
||||
SEPIA = 'filter-sepia',
|
||||
DARK = 'filter-dark'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
|
||||
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
||||
<img src="{{item.src}}" style="display: block"
|
||||
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}"
|
||||
*ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image"
|
||||
(load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
||||
</ng-container>
|
||||
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadNextChapter.emit()">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
width: 100% !important;
|
||||
}
|
||||
|
||||
// .img-container {
|
||||
// overflow: auto;
|
||||
// }
|
||||
|
||||
|
||||
@keyframes move-up-down {
|
||||
0%, 100% {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||
import { BehaviorSubject, fromEvent, merge, ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { ReaderService } from '../../_services/reader.service';
|
||||
import { PAGING_DIRECTION } from '../_models/reader-enums';
|
||||
import { WebtoonImage } from '../_models/webtoon-image';
|
||||
|
|
@ -63,7 +63,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
@Input() bookmarkPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
|
||||
@Input() fullscreenToggled: ReplaySubject<boolean> = new ReplaySubject<boolean>();
|
||||
|
||||
readerElemRef!: ElementRef<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* Stores and emits all the src urls
|
||||
*/
|
||||
|
|
@ -110,6 +113,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* If the user has scrolled all the way to the top. This is used solely for continuous reading
|
||||
*/
|
||||
atTop: boolean = false;
|
||||
/**
|
||||
* If the manga reader is in fullscreen. Some math changes based on this value.
|
||||
*/
|
||||
isFullscreenMode: boolean = false;
|
||||
/**
|
||||
* Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block
|
||||
*/
|
||||
|
|
@ -128,7 +135,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
get areImagesWiderThanWindow() {
|
||||
return this.webtoonImageWidth > (window.innerWidth || document.documentElement.clientWidth);
|
||||
let [innerWidth, _] = this.getInnerDimensions();
|
||||
return this.webtoonImageWidth > (innerWidth || document.documentElement.clientWidth);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -136,7 +144,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2) {}
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2) {
|
||||
// This will always exist at this point in time since this is used within manga reader
|
||||
const reader = document.querySelector('.reader');
|
||||
if (reader !== null) {
|
||||
this.readerElemRef = new ElementRef(reader as HTMLDivElement);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
||||
|
|
@ -151,11 +165,21 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
fromEvent(window, 'scroll')
|
||||
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
||||
/**
|
||||
* Responsible for binding the scroll handler to the correct event. On non-fullscreen, window is correct. However, on fullscreen, we must use the reader as that is what
|
||||
* gets promoted to fullscreen.
|
||||
*/
|
||||
initScrollHandler() {
|
||||
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : window, 'scroll')
|
||||
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
||||
.subscribe((event) => this.handleScrollEvent(event));
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initScrollHandler();
|
||||
|
||||
if (this.goToPage) {
|
||||
this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
|
||||
this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page);
|
||||
|
|
@ -183,6 +207,32 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fullscreenToggled) {
|
||||
this.fullscreenToggled.pipe(takeUntil(this.onDestroy)).subscribe(isFullscreen => {
|
||||
this.debugLog('[FullScreen] Fullscreen mode: ', isFullscreen);
|
||||
this.isFullscreenMode = isFullscreen;
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.initScrollHandler();
|
||||
this.setPageNum(this.pageNum, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getVerticalOffset() {
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window;
|
||||
|
||||
let offset = 0;
|
||||
if (reader instanceof Window) {
|
||||
offset = reader.scrollY;
|
||||
} else {
|
||||
offset = reader.scrollTop;
|
||||
}
|
||||
|
||||
return (offset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -191,9 +241,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* @param event Scroll Event
|
||||
*/
|
||||
handleScrollEvent(event?: any) {
|
||||
const verticalOffset = (window.pageYOffset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
// Need a fullscreen handler here too
|
||||
let verticalOffset = this.getVerticalOffset();
|
||||
|
||||
|
||||
if (verticalOffset > this.prevScrollPosition) {
|
||||
this.scrollingDirection = PAGING_DIRECTION.FORWARD;
|
||||
|
|
@ -202,6 +252,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
this.prevScrollPosition = verticalOffset;
|
||||
|
||||
console.log('CurrentPageElem: ', this.currentPageElem);
|
||||
if (this.currentPageElem != null) {
|
||||
console.log('Element Visible: ', this.isElementVisible(this.currentPageElem));
|
||||
}
|
||||
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
||||
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
|
||||
this.isScrolling = false;
|
||||
|
|
@ -230,9 +284,15 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
return totalHeight;
|
||||
}
|
||||
getTotalScroll() {
|
||||
if (this.isFullscreenMode) {
|
||||
return this.readerElemRef.nativeElement.offsetHeight + this.readerElemRef.nativeElement.scrollTop;
|
||||
}
|
||||
return document.documentElement.offsetHeight + document.documentElement.scrollTop;
|
||||
}
|
||||
getScrollTop() {
|
||||
if (this.isFullscreenMode) {
|
||||
return this.readerElemRef.nativeElement.scrollTop;
|
||||
}
|
||||
return document.documentElement.scrollTop;
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +327,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
this.atTop = true;
|
||||
// Scroll user back to original location
|
||||
this.previousScrollHeightMinusTop = document.documentElement.scrollHeight - document.documentElement.scrollTop;
|
||||
requestAnimationFrame(() => window.scrollTo(0, SPACER_SCROLL_INTO_PX));
|
||||
requestAnimationFrame(() => window.scrollTo(0, SPACER_SCROLL_INTO_PX)); // TODO: does this need to be fullscreen protected?
|
||||
} else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) {
|
||||
// If already at top, then we moving on
|
||||
this.loadPrevChapter.emit();
|
||||
|
|
@ -276,6 +336,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
}
|
||||
|
||||
getInnerDimensions() {
|
||||
let innerHeight = window.innerHeight;
|
||||
let innerWidth = window.innerWidth;
|
||||
|
||||
if (this.isFullscreenMode) {
|
||||
innerHeight = this.readerElemRef.nativeElement.clientHeight;
|
||||
innerWidth = this.readerElemRef.nativeElement.clientWidth;
|
||||
}
|
||||
return [innerHeight, innerWidth];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is any part of the element visible in the scrollport. Does not take into account
|
||||
* style properites, just scroll port visibility.
|
||||
|
|
@ -288,10 +359,16 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
// NOTE: This will say an element is visible if it is 1 px offscreen on top
|
||||
var rect = elem.getBoundingClientRect();
|
||||
|
||||
let [innerHeight, innerWidth] = this.getInnerDimensions();
|
||||
|
||||
|
||||
console.log('innerHeight: ', innerHeight);
|
||||
console.log('innerWidth: ', innerWidth);
|
||||
|
||||
return (rect.bottom >= 0 &&
|
||||
rect.right >= 0 &&
|
||||
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
rect.top <= (innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left <= (innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -306,12 +383,15 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
var rect = elem.getBoundingClientRect();
|
||||
|
||||
let [innerHeight, innerWidth] = this.getInnerDimensions();
|
||||
|
||||
|
||||
if (rect.bottom >= 0 &&
|
||||
rect.right >= 0 &&
|
||||
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
rect.top <= (innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left <= (innerWidth || document.documentElement.clientWidth)
|
||||
) {
|
||||
const topX = (window.innerHeight || document.documentElement.clientHeight);
|
||||
const topX = (innerHeight || document.documentElement.clientHeight);
|
||||
return Math.abs(rect.top / topX) <= 0.25;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -319,6 +399,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
|
||||
initWebtoonReader() {
|
||||
const [innerWidth, _] = this.getInnerDimensions();
|
||||
this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
this.imagesLoaded = {};
|
||||
this.webtoonImages.next([]);
|
||||
this.atBottom = false;
|
||||
|
|
@ -415,7 +497,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* Performs the scroll for the current page element. Updates any state variables needed.
|
||||
*/
|
||||
scrollToCurrentPage() {
|
||||
this.debugLog('Scrolling to ', this.pageNum);
|
||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||
if (!this.currentPageElem) { return; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="reader">
|
||||
<div class="reader" #reader [ngStyle]="{overflow: (isFullscreen ? 'auto' : 'visible')}">
|
||||
<div class="fixed-top overlay" *ngIf="menuOpen" [@slideFromTop]="menuOpen">
|
||||
<div style="display: flex; margin-top: 5px;">
|
||||
<button class="btn btn-icon" style="height: 100%" title="Back" (click)="closeReader()">
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
</ng-container>
|
||||
|
||||
<div (click)="toggleMenu()" class="reading-area">
|
||||
<canvas #content class="{{getFittingOptionClass()}} {{this.colorMode}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} "
|
||||
<canvas #content class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}"
|
||||
ondragstart="return false;" onselectstart="return false;">
|
||||
</canvas>
|
||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
||||
|
|
@ -36,11 +36,22 @@
|
|||
[urlProvider]="getPageUrl"
|
||||
(loadNextChapter)="loadNextChapter()"
|
||||
(loadPrevChapter)="loadPrevChapter()"
|
||||
[bookmarkPage]="showBookmarkEffectEvent"></app-infinite-scroller>
|
||||
[bookmarkPage]="showBookmarkEffectEvent"
|
||||
[fullscreenToggled]="fullscreenEvent"></app-infinite-scroller>
|
||||
</div>
|
||||
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')"></div>
|
||||
<div class="pagination-area {{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')">
|
||||
<div *ngIf="showClickOverlay">
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.LeftToRight ? 'double-' : ''}}{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'down'}}"
|
||||
title="Next Page" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination-area {{readerMode === READER_MODE.MANGA_LR ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')">
|
||||
<div *ngIf="showClickOverlay">
|
||||
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'up'}}"
|
||||
title="Previous Page" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,9 +89,9 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-icon {{this.colorMode}}" [disabled]="readerMode === READER_MODE.WEBTOON" title="Color Options: {{colorOptionName}}" (click)="toggleColorMode();resetMenuCloseTimer();">
|
||||
<i class="fa fa-palette" aria-hidden="true"></i>
|
||||
<span class="sr-only"></span>
|
||||
<button class="btn btn-icon" title="{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
|
||||
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}}" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{this.isFullscreen ? 'Collapse' : 'Fullscreen'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ $side-width: 25%;
|
|||
$dash-width: 3px;
|
||||
$pointer-offset: 5px;
|
||||
|
||||
$secondary-color: #CCC;
|
||||
|
||||
|
||||
@media(min-width: 600px) {
|
||||
.overlay .left .i {
|
||||
|
|
@ -18,7 +16,6 @@ $secondary-color: #CCC;
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.btn-icon {
|
||||
color: white;
|
||||
}
|
||||
|
|
@ -29,12 +26,17 @@ canvas {
|
|||
|
||||
.reader {
|
||||
background-color: black;
|
||||
overflow: auto;
|
||||
|
||||
img {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
left: 48%;
|
||||
|
|
@ -48,21 +50,16 @@ canvas {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-dark {
|
||||
filter: brightness(0.65);
|
||||
}
|
||||
|
||||
.filter-sepia {
|
||||
filter: sepia(80%) hue-rotate(349deg) saturate(200%) brightness(0.65);
|
||||
}
|
||||
|
||||
.bottom-menu {
|
||||
padding: 20px 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.overlay {
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(10px); // BUG: This doesn't work on Firefox
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
|
@ -226,15 +223,28 @@ canvas {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
.pagination-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
color: white;
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: rgba(65, 225, 100, 0.5) !important;
|
||||
animation: fadein .5s both;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.highlight-2 {
|
||||
background-color: rgba(65, 105, 225, 0.5) !important;
|
||||
animation: fadein .5s both;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
.bookmark-effect {
|
||||
animation: bookmark 0.7s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { DOCUMENT, Location } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { User } from '../_models/user';
|
||||
|
|
@ -19,7 +19,7 @@ import { Stack } from '../shared/data-structures/stack';
|
|||
import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider';
|
||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||
import { ChapterInfo } from './_models/chapter-info';
|
||||
import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
|
||||
import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
|
||||
import { pageSplitOptions, scalingOptions } from '../_models/preferences/preferences';
|
||||
import { READER_MODE } from '../_models/preferences/reader-mode';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
|
|
@ -32,7 +32,7 @@ const CHAPTER_ID_NOT_FETCHED = -2;
|
|||
const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
|
||||
const ANIMATION_SPEED = 200;
|
||||
const OVERLAY_AUTO_CLOSE_TIME = 6000;
|
||||
const OVERLAY_AUTO_CLOSE_TIME = 3000;
|
||||
const CLICK_OVERLAY_TIMEOUT = 3000;
|
||||
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
incognitoMode: boolean = false;
|
||||
|
||||
/**
|
||||
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
|
||||
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
|
||||
*/
|
||||
readingListMode: boolean = false;
|
||||
/**
|
||||
|
|
@ -99,14 +99,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
pageSplitOption = PageSplitOption.FitSplit;
|
||||
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
|
||||
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
||||
colorMode: COLOR_FILTER = COLOR_FILTER.NONE;
|
||||
isFullscreen: boolean = false;
|
||||
autoCloseMenu: boolean = true;
|
||||
readerMode: READER_MODE = READER_MODE.MANGA_LR;
|
||||
|
||||
pageSplitOptions = pageSplitOptions;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
@ViewChild('reader') reader!: ElementRef;
|
||||
@ViewChild('content') canvas: ElementRef | undefined;
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private canvasImage = new Image();
|
||||
|
|
@ -130,6 +131,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
* An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader.
|
||||
*/
|
||||
showBookmarkEffectEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
/**
|
||||
* An event emiter when fullscreen mode is toggled. Used soley by the webtoon reader.
|
||||
*/
|
||||
fullscreenEvent: ReplaySubject<boolean> = new ReplaySubject<boolean>();
|
||||
/**
|
||||
* If the menu is open/visible.
|
||||
*/
|
||||
|
|
@ -219,21 +224,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
||||
|
||||
getPageUrl = (pageNum: number) => this.readerService.getPageUrl(this.chapterId, pageNum);
|
||||
|
||||
|
||||
|
||||
|
||||
get pageBookmarked() {
|
||||
return this.bookmarks.hasOwnProperty(this.pageNum);
|
||||
}
|
||||
|
||||
|
||||
|
||||
get splitIconClass() {
|
||||
if (this.isSplitLeftToRight()) {
|
||||
return 'left-side';
|
||||
} else if (this.isNoSplit()) {
|
||||
return 'none';
|
||||
return 'none';
|
||||
}
|
||||
return 'right-side';
|
||||
}
|
||||
|
|
@ -249,17 +254,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
get colorOptionName() {
|
||||
switch(this.colorMode) {
|
||||
case COLOR_FILTER.NONE:
|
||||
return 'None';
|
||||
case COLOR_FILTER.DARK:
|
||||
return 'Dark';
|
||||
case COLOR_FILTER.SEPIA:
|
||||
return 'Sepia';
|
||||
}
|
||||
}
|
||||
|
||||
get READER_MODE(): typeof READER_MODE {
|
||||
return READER_MODE;
|
||||
}
|
||||
|
|
@ -274,10 +268,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||
public readerService: ReaderService, private location: Location,
|
||||
private formBuilder: FormBuilder, private navService: NavService,
|
||||
private formBuilder: FormBuilder, private navService: NavService,
|
||||
private toastr: ToastrService, private memberService: MemberService,
|
||||
private libraryService: LibraryService, private utilityService: UtilityService,
|
||||
private renderer: Renderer2) {
|
||||
private libraryService: LibraryService, private utilityService: UtilityService,
|
||||
private renderer: Renderer2, @Inject(DOCUMENT) private document: Document) {
|
||||
this.navService.hideNavBar();
|
||||
}
|
||||
|
||||
|
|
@ -295,13 +289,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.seriesId = parseInt(seriesId, 10);
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
|
||||
|
||||
|
||||
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
|
||||
if (readingListId != null) {
|
||||
this.readingListMode = true;
|
||||
this.readingListId = parseInt(readingListId, 10);
|
||||
}
|
||||
|
||||
|
||||
|
||||
this.continuousChaptersStack.push(this.chapterId);
|
||||
|
||||
|
|
@ -325,10 +319,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.updateForm();
|
||||
|
||||
|
||||
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
|
||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||
const needsSplitting = this.isCoverImage();
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
if (needsSplitting) {
|
||||
this.loadPage();
|
||||
}
|
||||
|
|
@ -341,7 +336,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
// If no user, we can't render
|
||||
// If no user, we can't render
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
});
|
||||
|
|
@ -365,6 +360,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.onDestroy.complete();
|
||||
this.goToPageEvent.complete();
|
||||
this.showBookmarkEffectEvent.complete();
|
||||
this.readerService.exitFullscreen();
|
||||
}
|
||||
|
||||
@HostListener('window:keyup', ['$event'])
|
||||
|
|
@ -407,6 +403,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
clickOverlayClass(side: 'right' | 'left') {
|
||||
if (!this.showClickOverlay) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
return side === 'right' ? 'highlight' : 'highlight-2';
|
||||
}
|
||||
return side === 'right' ? 'highlight-2' : 'highlight';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.nextChapterId = CHAPTER_ID_NOT_FETCHED;
|
||||
this.prevChapterId = CHAPTER_ID_NOT_FETCHED;
|
||||
|
|
@ -414,6 +421,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.prevChapterDisabled = false;
|
||||
this.nextChapterPrefetched = false;
|
||||
this.pageNum = 0;
|
||||
this.pagingDirection = PAGING_DIRECTION.FORWARD;
|
||||
|
||||
forkJoin({
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
|
|
@ -422,7 +430,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}).pipe(take(1)).subscribe(results => {
|
||||
|
||||
if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) {
|
||||
// Redirect to the book reader.
|
||||
// Redirect to the book reader.
|
||||
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
this.router.navigate(['library', results.chapterInfo.libraryId, 'series', results.chapterInfo.seriesId, 'book', this.chapterId], {queryParams: params});
|
||||
return;
|
||||
|
|
@ -432,11 +440,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.maxPages = results.chapterInfo.pages;
|
||||
let page = results.progress.pageNum;
|
||||
if (page > this.maxPages) {
|
||||
page = this.maxPages;
|
||||
page = this.maxPages - 1;
|
||||
}
|
||||
this.setPageNum(page);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Due to change detection rules in Angular, we need to re-create the options object to apply the change
|
||||
const newOptions: Options = Object.assign({}, this.pageOptions);
|
||||
|
|
@ -448,7 +456,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.updateTitle(results.chapterInfo, type);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// From bookmarks, create map of pages to make lookup time O(1)
|
||||
this.bookmarks = {};
|
||||
|
|
@ -504,7 +512,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
updateTitle(chapterInfo: ChapterInfo, type: LibraryType) {
|
||||
this.title = chapterInfo.seriesName;
|
||||
if (chapterInfo.chapterTitle.length > 0) {
|
||||
if (chapterInfo.chapterTitle != null && chapterInfo.chapterTitle.length > 0) {
|
||||
this.title += ' - ' + chapterInfo.chapterTitle;
|
||||
}
|
||||
|
||||
|
|
@ -562,7 +570,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
getFittingIcon() {
|
||||
const value = this.getFit();
|
||||
|
||||
|
||||
switch(value) {
|
||||
case FITTING_OPTION.HEIGHT:
|
||||
return 'fa-arrows-alt-v';
|
||||
|
|
@ -608,10 +616,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}, OVERLAY_AUTO_CLOSE_TIME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
toggleMenu() {
|
||||
this.menuOpen = !this.menuOpen;
|
||||
|
||||
|
||||
if (this.menuTimeout) {
|
||||
clearTimeout(this.menuTimeout);
|
||||
}
|
||||
|
|
@ -629,8 +637,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @returns If the current model reflects no split of fit split
|
||||
* @remarks Fit to Screen falls under no split
|
||||
*/
|
||||
isNoSplit() {
|
||||
const splitValue = parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10);
|
||||
|
|
@ -717,7 +726,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
if (this.readerMode !== READER_MODE.WEBTOON) {
|
||||
this.loadPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevPage(event?: any) {
|
||||
|
|
@ -745,7 +754,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
if (this.readerMode !== READER_MODE.WEBTOON) {
|
||||
this.loadPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadNextChapter() {
|
||||
|
|
@ -789,7 +798,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
|
||||
if (chapterId >= 0) {
|
||||
this.chapterId = chapterId;
|
||||
this.continuousChaptersStack.push(chapterId);
|
||||
this.continuousChaptersStack.push(chapterId);
|
||||
// Load chapter Id onto route but don't reload
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
|
|
@ -804,14 +813,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
} else {
|
||||
this.nextPageDisabled = true;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* There are some hard limits on the size of canvas' that we must cap at. https://github.com/jhildenbiddle/canvas-size#test-results
|
||||
* For Safari, it's 16,777,216, so we cap at 4096x4096 when this happens. The drawImage in render will perform bi-cubic scaling for us.
|
||||
* @returns If we should continue to the render loop
|
||||
* @returns If we should continue to the render loop
|
||||
*/
|
||||
setCanvasSize() {
|
||||
if (this.ctx && this.canvas) {
|
||||
|
|
@ -832,9 +841,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (needsScaling) {
|
||||
this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384;
|
||||
this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384;
|
||||
} else if (this.isCoverImage()) {
|
||||
//this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
//this.canvas.nativeElement.height = this.canvasImage.height;
|
||||
} else {
|
||||
this.canvas.nativeElement.width = this.canvasImage.width;
|
||||
this.canvas.nativeElement.height = this.canvasImage.height;
|
||||
|
|
@ -843,61 +849,67 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
renderPage() {
|
||||
if (this.ctx && this.canvas) {
|
||||
this.canvasImage.onload = null;
|
||||
if (!this.ctx || !this.canvas) { return; }
|
||||
|
||||
this.setCanvasSize();
|
||||
this.canvasImage.onload = null;
|
||||
|
||||
const needsSplitting = this.isCoverImage();
|
||||
this.updateSplitPage();
|
||||
this.setCanvasSize();
|
||||
|
||||
if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) {
|
||||
this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, 0, 0, this.canvasImage.width, this.canvasImage.height);
|
||||
} else if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.RIGHT_PART) {
|
||||
this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
|
||||
} else {
|
||||
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
|
||||
this.updateScalingForFirstPageRender();
|
||||
}
|
||||
const needsSplitting = this.isCoverImage();
|
||||
this.updateSplitPage();
|
||||
|
||||
// Fit Split on a page that needs splitting
|
||||
if (this.shouldRenderAsFitSplit()) {
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
// If the user's screen is wider than the image, just pretend this is no split, as it will render nicer
|
||||
this.canvas.nativeElement.width = windowWidth;
|
||||
this.canvas.nativeElement.height = windowHeight;
|
||||
const ratio = this.canvasImage.width / this.canvasImage.height;
|
||||
let newWidth = windowWidth;
|
||||
let newHeight = newWidth / ratio;
|
||||
if (newHeight > windowHeight) {
|
||||
newHeight = windowHeight;
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) {
|
||||
this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, 0, 0, this.canvasImage.width, this.canvasImage.height);
|
||||
} else if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.RIGHT_PART) {
|
||||
this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
|
||||
} else {
|
||||
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
|
||||
this.updateScalingForFirstPageRender();
|
||||
}
|
||||
|
||||
// Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit
|
||||
if (windowWidth > newWidth) {
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||
} else {
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight);
|
||||
}
|
||||
} else {
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||
}
|
||||
// Fit Split on a page that needs splitting
|
||||
if (!this.shouldRenderAsFitSplit()) {
|
||||
this.setCanvasSize();
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset scroll on non HEIGHT Fits
|
||||
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
|
||||
window.scrollTo(0, 0);
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
// If the user's screen is wider than the image, just pretend this is no split, as it will render nicer
|
||||
this.canvas.nativeElement.width = windowWidth;
|
||||
this.canvas.nativeElement.height = windowHeight;
|
||||
const ratio = this.canvasImage.width / this.canvasImage.height;
|
||||
let newWidth = windowWidth;
|
||||
let newHeight = newWidth / ratio;
|
||||
if (newHeight > windowHeight) {
|
||||
newHeight = windowHeight;
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
|
||||
// Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit
|
||||
if (windowWidth > newWidth) {
|
||||
this.setCanvasSize();
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||
} else {
|
||||
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
||||
this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset scroll on non HEIGHT Fits
|
||||
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
|
|
@ -908,13 +920,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
|
||||
|
||||
const needsSplitting = this.isCoverImage();
|
||||
let newScale = this.generalSettingsForm.get('fittingOption')?.value;
|
||||
const widthRatio = windowWidth / (this.canvasImage.width / (needsSplitting ? 2 : 1));
|
||||
const heightRatio = windowHeight / (this.canvasImage.height);
|
||||
|
||||
// Given that we now have image dimensions, assuming this isn't a split image,
|
||||
// Given that we now have image dimensions, assuming this isn't a split image,
|
||||
// Try to reset one time based on who's dimension (width/height) is smaller
|
||||
if (widthRatio < heightRatio) {
|
||||
newScale = FITTING_OPTION.WIDTH;
|
||||
|
|
@ -932,7 +944,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
|
||||
shouldRenderAsFitSplit() {
|
||||
if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
|
||||
// Some pages aren't cover images but might need fit split renderings
|
||||
if (parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
|
||||
//if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -952,7 +966,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
index += 1;
|
||||
}
|
||||
}, this.cachedImages.size() - 3);
|
||||
//console.log('prefetched images: ', this.cachedImages.arr.map(item => this.readerService.imageUrlToPageNum(item.src) + (item.complete ? ' (c)' : '')));
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
|
|
@ -984,19 +997,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
clickOverlayClass(side: 'right' | 'left') {
|
||||
if (!this.showClickOverlay) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.readingDirection === ReadingDirection.LeftToRight) {
|
||||
return side === 'right' ? 'highlight' : 'highlight-2';
|
||||
}
|
||||
return side === 'right' ? 'highlight-2' : 'highlight';
|
||||
}
|
||||
|
||||
sliderDragUpdate(context: ChangeContext) {
|
||||
// This will update the value for value except when in webtoon due to how the webtoon reader
|
||||
// This will update the value for value except when in webtoon due to how the webtoon reader
|
||||
// responds to page changes
|
||||
if (this.readerMode !== READER_MODE.WEBTOON) {
|
||||
this.setPageNum(context.value);
|
||||
|
|
@ -1005,7 +1008,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
sliderPageUpdate(context: ChangeContext) {
|
||||
const page = context.value;
|
||||
|
||||
|
||||
if (page > this.pageNum) {
|
||||
this.pagingDirection = PAGING_DIRECTION.FORWARD;
|
||||
} else {
|
||||
|
|
@ -1038,7 +1041,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
|
||||
let tempPageNum = this.pageNum;
|
||||
if (this.pageNum == this.maxPages - 1) {
|
||||
if (this.pageNum == this.maxPages - 1 && this.pagingDirection === PAGING_DIRECTION.FORWARD) {
|
||||
tempPageNum = this.pageNum + 1;
|
||||
}
|
||||
|
||||
|
|
@ -1049,7 +1052,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
goToPage(pageNum: number) {
|
||||
let page = pageNum;
|
||||
|
||||
|
||||
if (page === undefined || this.pageNum === page) { return; }
|
||||
|
||||
if (page > this.maxPages) {
|
||||
|
|
@ -1079,20 +1082,26 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
return goToPageNum;
|
||||
}
|
||||
|
||||
toggleColorMode() {
|
||||
switch(this.colorMode) {
|
||||
case COLOR_FILTER.NONE:
|
||||
this.colorMode = COLOR_FILTER.DARK;
|
||||
break;
|
||||
case COLOR_FILTER.DARK:
|
||||
this.colorMode = COLOR_FILTER.SEPIA;
|
||||
break;
|
||||
case COLOR_FILTER.SEPIA:
|
||||
this.colorMode = COLOR_FILTER.NONE;
|
||||
break;
|
||||
toggleFullscreen() {
|
||||
this.isFullscreen = this.readerService.checkFullscreenMode();
|
||||
if (this.isFullscreen) {
|
||||
this.readerService.exitFullscreen(() => {
|
||||
this.isFullscreen = false;
|
||||
this.firstPageRendered = false;
|
||||
this.fullscreenEvent.next(false);
|
||||
this.render();
|
||||
});
|
||||
} else {
|
||||
this.readerService.enterFullscreen(this.reader.nativeElement, () => {
|
||||
this.isFullscreen = true;
|
||||
this.firstPageRendered = false;
|
||||
this.fullscreenEvent.next(true);
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleReaderMode() {
|
||||
switch(this.readerMode) {
|
||||
case READER_MODE.MANGA_LR:
|
||||
|
|
@ -1163,4 +1172,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
|
||||
getWindowDimensions() {
|
||||
const windowWidth = window.innerWidth
|
||||
|| document.documentElement.clientWidth
|
||||
|| document.body.clientWidth;
|
||||
const windowHeight = window.innerHeight
|
||||
|| document.documentElement.clientHeight
|
||||
|| document.body.clientHeight;
|
||||
return [windowWidth, windowHeight];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<ng-container>
|
||||
|
||||
<button type="button" class="btn btn-icon {{progressEventsSource.getValue().length > 0 ? 'colored' : ''}}"
|
||||
<button type="button" class="btn btn-icon {{(progressEventsSource.getValue().length > 0 || updateAvailable) ? 'colored' : ''}}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square"></i>
|
||||
</button>
|
||||
|
|
@ -14,9 +14,12 @@
|
|||
<span class="sr-only">Scan for {{event.libraryName}} in progress</span>
|
||||
</div>
|
||||
{{prettyPrintProgress(event.progress)}}%
|
||||
{{prettyPrintEvent(event.eventType)}} {{event.libraryName}}
|
||||
{{prettyPrintEvent(event.eventType, event)}} {{event.libraryName}}
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item" *ngIf="progressEventsSource.getValue().length === 0 && !updateAvailable">Not much going on here</li>
|
||||
<li class="list-group-item dark-menu-item update-available" *ngIf="updateAvailable" (click)="handleUpdateAvailableClick()">
|
||||
<i class="fa fa-chevron-circle-up" aria-hidden="true"></i> Update available
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item" *ngIf="progressEventsSource.getValue().length === 0">Not much going on here</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
@import "../../theme/colors";
|
||||
@use "../../theme/colors";
|
||||
|
||||
.small-spinner {
|
||||
width: 1rem;
|
||||
|
|
@ -18,6 +18,15 @@
|
|||
}
|
||||
|
||||
.colored {
|
||||
background-color: $primary-color;
|
||||
background-color: colors.$primary-color;
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.update-available {
|
||||
cursor: pointer;
|
||||
|
||||
i.fa {
|
||||
color: colors.$primary-color !important;
|
||||
}
|
||||
color: colors.$primary-color;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
||||
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
|
||||
import { User } from '../_models/user';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
|
|
@ -16,6 +18,8 @@ interface ProcessedEvent {
|
|||
|
||||
type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress;
|
||||
|
||||
const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgress, EVENTS.BackupDatabaseProgress, EVENTS.CleanupProgress, EVENTS.DownloadProgress];
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-events-toggle',
|
||||
templateUrl: './nav-events-toggle.component.html',
|
||||
|
|
@ -33,7 +37,11 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
progressEventsSource = new BehaviorSubject<ProcessedEvent[]>([]);
|
||||
progressEvents$ = this.progressEventsSource.asObservable();
|
||||
|
||||
constructor(private messageHub: MessageHubService, private libraryService: LibraryService) { }
|
||||
updateAvailable: boolean = false;
|
||||
updateBody: any;
|
||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private messageHub: MessageHubService, private libraryService: LibraryService, private modalService: NgbModal) { }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
|
|
@ -43,8 +51,11 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
||||
if (event.event === EVENTS.ScanLibraryProgress || event.event === EVENTS.RefreshMetadataProgress || event.event === EVENTS.BackupDatabaseProgress || event.event === EVENTS.CleanupProgress) {
|
||||
if (acceptedEvents.includes(event.event)) {
|
||||
this.processProgressEvent(event, event.event);
|
||||
} else if (event.event === EVENTS.UpdateAvailable) {
|
||||
this.updateAvailable = true;
|
||||
this.updateBody = event.payload;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -64,7 +75,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
|
||||
if (scanEvent.progress !== 1) {
|
||||
const libraryName = names[scanEvent.libraryId] || '';
|
||||
const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName};
|
||||
const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName, rawBody: event.payload};
|
||||
data.push(newEvent);
|
||||
}
|
||||
|
||||
|
|
@ -73,16 +84,29 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
handleUpdateAvailableClick() {
|
||||
if (this.updateNotificationModalRef != null) { return; }
|
||||
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
this.updateNotificationModalRef.componentInstance.updateData = this.updateBody;
|
||||
this.updateNotificationModalRef.closed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
this.updateNotificationModalRef.dismissed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
}
|
||||
|
||||
prettyPrintProgress(progress: number) {
|
||||
return Math.trunc(progress * 100);
|
||||
}
|
||||
|
||||
prettyPrintEvent(eventType: string) {
|
||||
prettyPrintEvent(eventType: string, event: any) {
|
||||
switch(eventType) {
|
||||
case (EVENTS.ScanLibraryProgress): return 'Scanning ';
|
||||
case (EVENTS.RefreshMetadataProgress): return 'Refreshing ';
|
||||
case (EVENTS.RefreshMetadataProgress): return 'Refreshing Covers for ';
|
||||
case (EVENTS.CleanupProgress): return 'Clearing Cache';
|
||||
case (EVENTS.BackupDatabaseProgress): return 'Backing up Database';
|
||||
case (EVENTS.DownloadProgress): return event.rawBody.userName.charAt(0).toUpperCase() + event.rawBody.userName.substr(1) + ' is downloading ' + event.rawBody.downloadName;
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '~bootstrap/scss/mixins/_breakpoints.scss'; // TODO: Use @forwards for this?
|
||||
@import '~bootstrap/scss/mixins/_breakpoints.scss';
|
||||
|
||||
$primary-color: white;
|
||||
$bg-color: rgb(22, 27, 34);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
<app-card-detail-layout header="On Deck"
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[filters]="filters"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
(pageChange)="onPageChange($event)"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { Title } from '@angular/platform-browser';
|
|||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { FilterItem, SeriesFilter, mangaFormatFilters } from '../_models/series-filter';
|
||||
import { SeriesFilter} from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
|
@ -23,10 +23,8 @@ export class OnDeckComponent implements OnInit {
|
|||
series: Series[] = [];
|
||||
pagination!: Pagination;
|
||||
libraryId!: number;
|
||||
filters: Array<FilterItem> = mangaFormatFilters;
|
||||
filter: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
};
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
||||
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
||||
|
|
@ -35,6 +33,8 @@ export class OnDeckComponent implements OnInit {
|
|||
if (this.pagination === undefined || this.pagination === null) {
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
}
|
||||
this.filterSettings.readProgressDisabled = true;
|
||||
this.filterSettings.sortDisabled = true;
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
|
|
@ -63,8 +63,8 @@ export class OnDeckComponent implements OnInit {
|
|||
this.loadPage();
|
||||
}
|
||||
|
||||
updateFilter(data: UpdateFilterEvent) {
|
||||
this.filter.mangaFormat = data.filterItem.value;
|
||||
updateFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<div class="badge">
|
||||
<!-- Put a person image container here -->
|
||||
<div class="img">
|
||||
|
||||
</div>
|
||||
<div class="">
|
||||
<ng-content select="[name]"></ng-content>
|
||||
<div style="font-size: 12px">
|
||||
<ng-content select="[role]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
26
UI/Web/src/app/person-role.pipe.ts
Normal file
26
UI/Web/src/app/person-role.pipe.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { PersonRole } from './_models/person';
|
||||
|
||||
@Pipe({
|
||||
name: 'personRole'
|
||||
})
|
||||
export class PersonRolePipe implements PipeTransform {
|
||||
|
||||
transform(value: PersonRole): string {
|
||||
switch (value) {
|
||||
case PersonRole.Artist: return 'Artist';
|
||||
case PersonRole.Character: return 'Character';
|
||||
case PersonRole.Colorist: return 'Colorist';
|
||||
case PersonRole.CoverArtist: return 'CoverArtist';
|
||||
case PersonRole.Editor: return 'Editor';
|
||||
case PersonRole.Inker: return 'Inker';
|
||||
case PersonRole.Letterer: return 'Letterer';
|
||||
case PersonRole.Penciller: return 'Penciller';
|
||||
case PersonRole.Publisher: return 'Publisher';
|
||||
case PersonRole.Writer: return 'Writer';
|
||||
case PersonRole.Other: return '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
19
UI/Web/src/app/publication-status.pipe.ts
Normal file
19
UI/Web/src/app/publication-status.pipe.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { PublicationStatus } from './_models/metadata/publication-status';
|
||||
|
||||
@Pipe({
|
||||
name: 'publicationStatus'
|
||||
})
|
||||
export class PublicationStatusPipe implements PipeTransform {
|
||||
|
||||
transform(value: PublicationStatus): string {
|
||||
switch (value) {
|
||||
case PublicationStatus.OnGoing: return 'On Going';
|
||||
case PublicationStatus.Hiatus: return 'Hiatus';
|
||||
case PublicationStatus.Completed: return 'Completed';
|
||||
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -157,7 +157,11 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
|
||||
removeRead() {
|
||||
this.isLoading = true;
|
||||
this.readingListService.removeRead(this.readingList.id).subscribe(() => {
|
||||
this.readingListService.removeRead(this.readingList.id).subscribe((resp) => {
|
||||
if (resp === 'Nothing to remove') {
|
||||
this.toastr.info(resp);
|
||||
return;
|
||||
}
|
||||
this.getListItems();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filters]="filters"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
[filterSettings]="filterSettings"
|
||||
(applyFilter)="applyFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { MessageHubService } from '../_services/message-hub.service';
|
||||
|
|
@ -30,10 +30,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||
pagination!: Pagination;
|
||||
libraryId!: number;
|
||||
|
||||
filters: Array<FilterItem> = mangaFormatFilters;
|
||||
filter: SeriesFilter = {
|
||||
mangaFormat: null
|
||||
};
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
||||
onDestroy: Subject<void> = new Subject();
|
||||
|
||||
|
|
@ -44,6 +42,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||
if (this.pagination === undefined || this.pagination === null) {
|
||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
}
|
||||
this.filterSettings.sortDisabled = true;
|
||||
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
|||
this.loadPage();
|
||||
}
|
||||
|
||||
updateFilter(data: UpdateFilterEvent) {
|
||||
this.filter.mangaFormat = data.filterItem.value;
|
||||
applyFilter(data: SeriesFilter) {
|
||||
this.filter = data;
|
||||
if (this.pagination !== undefined && this.pagination !== null) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group" *ngIf="registerForm.get('isAdmin')?.value || !authDisabled">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" class="form-control" formControlName="password" type="password">
|
||||
<label for="password">Password</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #passwordTooltip>
|
||||
Password must be between 6 and 32 characters in length
|
||||
</ng-template>
|
||||
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
||||
<input id="password" class="form-control" formControlName="password" type="password" aria-describedby="password-help">
|
||||
</div>
|
||||
|
||||
<div class="form-check" *ngIf="!firstTimeFlow">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { ElementRef, Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -13,8 +13,8 @@ export class ScrollService {
|
|||
|| document.body.scrollTop || 0);
|
||||
}
|
||||
|
||||
scrollTo(top: number) {
|
||||
window.scroll({
|
||||
scrollTo(top: number, el: Element | Window = window) {
|
||||
el.scroll({
|
||||
top: top,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,50 +49,14 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
<!-- TODO: This will be the first of reviews section. Reviews will show your plus other peoples reviews in media cards like Plex does and this will be below metadata -->
|
||||
<app-read-more class="user-review {{userReview ? 'mt-1' : ''}}" [text]="series?.userReview || ''" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
<div class="row no-gutters {{series?.userReview ? '' : 'mt-2'}}">
|
||||
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
<div *ngIf="seriesMetadata" class="mt-2">
|
||||
<div class="row no-gutters" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Genres</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-tag-badge *ngFor="let genre of seriesMetadata.genres" [selectionMode]="TagBadgeCursor.Clickable">{{genre}}</app-tag-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.tags && seriesMetadata.tags.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Collections</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-tag-badge *ngFor="let tag of seriesMetadata.tags" a11y-click="13,32" class="clickable" routerLink="/collections/{{tag.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
{{tag.title}}
|
||||
</app-tag-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.persons && seriesMetadata.persons.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>People</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of seriesMetadata.persons">
|
||||
<div name>{{person.name}}</div>
|
||||
<div role>{{person.role}}</div>
|
||||
</app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="series.format != MangaFormat.UNKNOWN">
|
||||
<div class="col-md-4">
|
||||
<h5>Type</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed"><app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format></app-tag-badge>
|
||||
</div>
|
||||
</div>
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [series]="series"></app-series-metadata-detail>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -116,12 +80,12 @@
|
|||
<ng-template ngbNavContent>
|
||||
<div class="row no-gutters">
|
||||
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
|
||||
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="formatVolumeTitle(volume)" (click)="openVolume(volume)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
|
||||
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
<div *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="utilityService.formatChapterName(libraryType, true, true) + chapter.range" (click)="openChapter(chapter)"
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="formatChapterTitle(chapter)" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Title } from '@angular/platform-browser';
|
|||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
|
||||
|
|
@ -15,6 +15,7 @@ import { DownloadService } from '../shared/_services/download.service';
|
|||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
|
||||
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
||||
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||
import { LibraryType } from '../_models/library';
|
||||
|
|
@ -62,7 +63,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
activeTabId = 2;
|
||||
hasNonSpecialVolumeChapters = true;
|
||||
|
||||
seriesSummary: string = '';
|
||||
userReview: string = '';
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
seriesMetadata: SeriesMetadata | null = null;
|
||||
|
|
@ -147,7 +147,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private seriesService: SeriesService,
|
||||
private ratingConfig: NgbRatingConfig, private router: Router,
|
||||
private router: Router, public bulkSelectionService: BulkSelectionService,
|
||||
private modalService: NgbModal, public readerService: ReaderService,
|
||||
public utilityService: UtilityService, private toastr: ToastrService,
|
||||
private accountService: AccountService, public imageService: ImageService,
|
||||
|
|
@ -155,8 +155,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
private confirmService: ConfirmService, private titleService: Title,
|
||||
private downloadService: DownloadService, private actionService: ActionService,
|
||||
public imageSerivce: ImageService, private messageHub: MessageHubService,
|
||||
public bulkSelectionService: BulkSelectionService) {
|
||||
ratingConfig.max = 5;
|
||||
) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
|
|
@ -188,16 +187,21 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
this.toastr.info('This series no longer exists');
|
||||
this.router.navigateByUrl('/libraries');
|
||||
}
|
||||
} else if (event.event === EVENTS.RefreshMetadata) {
|
||||
const seriesRemovedEvent = event.payload as RefreshMetadataEvent;
|
||||
if (seriesRemovedEvent.seriesId === this.series.id) {
|
||||
this.seriesService.getMetadata(this.series.id).pipe(take(1)).subscribe(metadata => {
|
||||
this.seriesMetadata = metadata;
|
||||
this.createHTML();
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const seriesId = parseInt(routeId, 10);
|
||||
this.libraryId = parseInt(libraryId, 10);
|
||||
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
|
||||
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
this.loadSeries(seriesId);
|
||||
});
|
||||
this.loadSeries(seriesId);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
|
@ -249,6 +253,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
case(Action.AddToReadingList):
|
||||
this.actionService.addSeriesToReadingList(series, () => this.actionInProgress = false);
|
||||
break;
|
||||
case(Action.AddToCollection):
|
||||
this.actionService.addMultipleSeriesToCollectionTag([series], () => this.actionInProgress = false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -302,17 +309,11 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
|
||||
|
||||
async deleteSeries(series: Series) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) {
|
||||
this.actionService.deleteSeries(series, (result: boolean) => {
|
||||
this.actionInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||
if (res) {
|
||||
this.toastr.success('Series deleted');
|
||||
if (result) {
|
||||
this.router.navigate(['library', this.libraryId]);
|
||||
}
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -334,11 +335,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
|
||||
loadSeries(seriesId: number) {
|
||||
this.coverImageOffset = 0;
|
||||
this.seriesService.getMetadata(seriesId).subscribe(metadata => {
|
||||
this.seriesMetadata = metadata;
|
||||
});
|
||||
this.seriesService.getSeries(seriesId).subscribe(series => {
|
||||
this.series = series;
|
||||
|
||||
forkJoin([
|
||||
this.libraryService.getLibraryType(this.libraryId),
|
||||
this.seriesService.getMetadata(seriesId),
|
||||
this.seriesService.getSeries(seriesId)
|
||||
]).subscribe(results => {
|
||||
this.libraryType = results[0];
|
||||
this.seriesMetadata = results[1];
|
||||
this.series = results[2];
|
||||
|
||||
this.createHTML();
|
||||
|
||||
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
||||
|
|
@ -387,7 +393,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
createHTML() {
|
||||
this.seriesSummary = (this.series.summary === null ? '' : this.series.summary).replace(/\n/g, '<br>');
|
||||
this.userReview = (this.series.userReview === null ? '' : this.series.userReview).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
|
|
@ -572,4 +577,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
})).subscribe(() => {/* No Operation */});;
|
||||
});
|
||||
}
|
||||
|
||||
formatChapterTitle(chapter: Chapter) {
|
||||
return this.utilityService.formatChapterName(this.libraryType, true, true) + chapter.range;
|
||||
}
|
||||
|
||||
formatVolumeTitle(volume: Volume) {
|
||||
return 'Volume ' + volume.name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
<div class="row no-gutters mt-2 mb-2">
|
||||
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
|
||||
<!-- This first row will have random information about the series-->
|
||||
<div class="row no-gutters mb-2">
|
||||
<app-tag-badge title="Age Rating" *ngIf="seriesMetadata.ageRating" a11y-click="13,32" class="clickable" (click)="goTo('ageRating', seriesMetadata.ageRating)" [selectionMode]="TagBadgeCursor.Clickable">{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}</app-tag-badge>
|
||||
<ng-container *ngIf="series">
|
||||
<!-- Maybe we can put the library this resides in to make it easier to get back -->
|
||||
<!-- tooltip here explaining how this is year of first issue -->
|
||||
<app-tag-badge *ngIf="seriesMetadata.releaseYear > 0" title="Release date">{{seriesMetadata.releaseYear}}</app-tag-badge>
|
||||
<app-tag-badge *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''" title="Language" a11y-click="13,32" class="clickable" (click)="goTo('languages', seriesMetadata.language)" [selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.language}}</app-tag-badge>
|
||||
<app-tag-badge title="Publication Status" a11y-click="13,32" class="clickable" (click)="goTo('publicationStatus', seriesMetadata.publicationStatus)" [selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.publicationStatus | publicationStatus}}</app-tag-badge>
|
||||
<app-tag-badge a11y-click="13,32" class="clickable" (click)="goTo('format', series.format)" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
<app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format>
|
||||
</app-tag-badge>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Genres</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.genres">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge a11y-click="13,32" class="clickable" (click)="goTo('genres', item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.collectionTags && seriesMetadata.collectionTags.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Collections</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.collectionTags">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge a11y-click="13,32" class="clickable" routerLink="/collections/{{item.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.writers && seriesMetadata.writers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Writers/Authors</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.writers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('writers', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<hr class="col-md-11" *ngIf="hasExtendedProperites" >
|
||||
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites" class="col-md-1 read-more-link" (click)="toggleView()"> <i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}}" aria-controls="extended-series-metadata"></i> See {{isCollapsed ? 'More' : 'Less'}}</a>
|
||||
</div>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata">
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.coverArtists && seriesMetadata.coverArtists.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Cover Artists</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.coverArtists">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('coverArtists', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.characters && seriesMetadata.characters.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Characters</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.characters">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('character', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.colorists && seriesMetadata.colorists.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Colorists</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.colorists">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('colorist', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.editors && seriesMetadata.editors.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Editors</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.editors">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('editor', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.inkers && seriesMetadata.inkers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Inkers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.inkers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('inker', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.letterers && seriesMetadata.letterers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Letterers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.letterers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('letterer', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters" *ngIf="seriesMetadata.tags && seriesMetadata.tags.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Tags</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.tags">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge a11y-click="13,32" class="clickable" (click)="goTo('tags', item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.translators && seriesMetadata.translators.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Translators</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.translators">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('translators', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.pencillers && seriesMetadata.pencillers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Pencillers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.pencillers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('penciller', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters mt-1" *ngIf="seriesMetadata.publishers && seriesMetadata.publishers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Publishers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="seriesMetadata.publishers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="clickable" (click)="goTo('publisher', item.id)" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.read-more-link {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesMetadata } from '../_models/series-metadata';
|
||||
import { MetadataService } from '../_services/metadata.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-metadata-detail',
|
||||
templateUrl: './series-metadata-detail.component.html',
|
||||
styleUrls: ['./series-metadata-detail.component.scss']
|
||||
})
|
||||
export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input() seriesMetadata!: SeriesMetadata;
|
||||
@Input() series!: Series;
|
||||
|
||||
isCollapsed: boolean = true;
|
||||
hasExtendedProperites: boolean = false;
|
||||
|
||||
/**
|
||||
* Html representation of Series Summary
|
||||
*/
|
||||
seriesSummary: string = '';
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get TagBadgeCursor(): typeof TagBadgeCursor {
|
||||
return TagBadgeCursor;
|
||||
}
|
||||
|
||||
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private router: Router) { }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 ||
|
||||
this.seriesMetadata.editors.length > 0 ||
|
||||
this.seriesMetadata.coverArtists.length > 0 ||
|
||||
this.seriesMetadata.inkers.length > 0 ||
|
||||
this.seriesMetadata.letterers.length > 0 ||
|
||||
this.seriesMetadata.pencillers.length > 0 ||
|
||||
this.seriesMetadata.publishers.length > 0 ||
|
||||
this.seriesMetadata.translators.length > 0 ||
|
||||
this.seriesMetadata.tags.length > 0;
|
||||
|
||||
if (this.seriesMetadata !== null) {
|
||||
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
toggleView() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
}
|
||||
|
||||
goTo(queryParamName: string, filter: any) {
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params['page'] = 1;
|
||||
this.router.navigate(['library', this.series.libraryId], {queryParams: params});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
|
||||
export enum KEY_CODES {
|
||||
|
|
@ -87,6 +91,122 @@ export class UtilityService {
|
|||
return cleaned;
|
||||
}
|
||||
|
||||
filter(input: string, filter: string): boolean {
|
||||
if (input === null || filter === null) return false;
|
||||
const reg = /[_\.\-]/gi;
|
||||
return input.toUpperCase().replace(reg, '').includes(filter.toUpperCase().replace(reg, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new instance of a filterSettings that is populated with filter presets from URL
|
||||
* @param snapshot
|
||||
* @param blankFilter Filter to start with
|
||||
* @returns The Preset filter and if something was set within
|
||||
*/
|
||||
filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot, blankFilter: SeriesFilter): [SeriesFilter, boolean] {
|
||||
const filter = Object.assign({}, blankFilter);
|
||||
let anyChanged = false;
|
||||
|
||||
const format = snapshot.queryParamMap.get('format');
|
||||
if (format !== undefined && format !== null) {
|
||||
filter.formats = [...filter.formats, ...format.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const genres = snapshot.queryParamMap.get('genres');
|
||||
if (genres !== undefined && genres !== null) {
|
||||
filter.genres = [...filter.genres, ...genres.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const ageRating = snapshot.queryParamMap.get('ageRating');
|
||||
if (ageRating !== undefined && ageRating !== null) {
|
||||
filter.ageRating = [...filter.ageRating, ...ageRating.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const publicationStatus = snapshot.queryParamMap.get('publicationStatus');
|
||||
if (publicationStatus !== undefined && publicationStatus !== null) {
|
||||
filter.publicationStatus = [...filter.publicationStatus, ...publicationStatus.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const tags = snapshot.queryParamMap.get('tags');
|
||||
if (tags !== undefined && tags !== null) {
|
||||
filter.tags = [...filter.tags, ...tags.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const languages = snapshot.queryParamMap.get('languages');
|
||||
if (languages !== undefined && languages !== null) {
|
||||
filter.languages = [...filter.languages, ...languages.split(',')];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const writers = snapshot.queryParamMap.get('writers');
|
||||
if (writers !== undefined && writers !== null) {
|
||||
filter.writers = [...filter.writers, ...writers.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const character = snapshot.queryParamMap.get('character');
|
||||
if (character !== undefined && character !== null) {
|
||||
filter.character = [...filter.character, ...character.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const colorist = snapshot.queryParamMap.get('colorist');
|
||||
if (colorist !== undefined && colorist !== null) {
|
||||
filter.colorist = [...filter.colorist, ...colorist.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const coverArtists = snapshot.queryParamMap.get('coverArtists');
|
||||
if (coverArtists !== undefined && coverArtists !== null) {
|
||||
filter.coverArtist = [...filter.coverArtist, ...coverArtists.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const editor = snapshot.queryParamMap.get('editor');
|
||||
if (editor !== undefined && editor !== null) {
|
||||
filter.editor = [...filter.editor, ...editor.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const inker = snapshot.queryParamMap.get('inker');
|
||||
if (inker !== undefined && inker !== null) {
|
||||
filter.inker = [...filter.inker, ...inker.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const letterer = snapshot.queryParamMap.get('letterer');
|
||||
if (letterer !== undefined && letterer !== null) {
|
||||
filter.letterer = [...filter.letterer, ...letterer.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const penciller = snapshot.queryParamMap.get('penciller');
|
||||
if (penciller !== undefined && penciller !== null) {
|
||||
filter.penciller = [...filter.penciller, ...penciller.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const publisher = snapshot.queryParamMap.get('publisher');
|
||||
if (publisher !== undefined && publisher !== null) {
|
||||
filter.publisher = [...filter.publisher, ...publisher.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const translators = snapshot.queryParamMap.get('translators');
|
||||
if (translators !== undefined && translators !== null) {
|
||||
filter.translators = [...filter.translators, ...translators.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
|
||||
return [filter, anyChanged];
|
||||
}
|
||||
|
||||
mangaFormat(format: MangaFormat): string {
|
||||
switch (format) {
|
||||
case MangaFormat.EPUB:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<div class="badge-expander">
|
||||
<div class="content">
|
||||
<ng-container *ngFor="let item of visibleItems; index as i;" [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
<button type="button" *ngIf="!isCollapsed && itemsLeft !== 0" class="btn btn-outline-primary" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
|
||||
and {{itemsLeft}} more
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue