v0.5.2 - UX Refresh, Custom Themes, Metadata Editing, and OPDS updates! (#1189)

* Angular Upgrade (#1059)

* Upgraded to Angular 12

* Bump ng-bootstrap for upgrade

* Angular 13 upgrade, ng-bootstrap bump

* Angular 13 upgrade (broken)

* Angular 13 upgrade. CSS is broken completely

* Angular 13 upgrade is complete.

* Bump versions by dotnet-bump-version.

* Added beta disclaimer (#1065)

* Bump versions by dotnet-bump-version.

* Auto approve migration emails if the password is correct. Change Email Link dump to Critical to ensure it makes it into the logs. (#1069)

* Bump versions by dotnet-bump-version.

* Adding discord roles (#1070)

* Adding discord roles

# Added
- Added: Added Discord roles to automated build discord notification.

* update

* Bump versions by dotnet-bump-version.

* Custom Theme Support (#1077)

* Started the migration to bootstrap 5. Introduced a breakpoint system that bootstrap reflects for our screens.

* sr only migrated

* mr/ml -> me/ms

* pl/pr -> ps/pe

* btn-block

* removed input-group-append

* Added form-label to all labels

* Added some style overrides for inputs

* Replaced form-group with mb-3

* Ignore journal files

* Update media to d-flex/flex-grow-1

* Fixed reading list detail page

* For develop builds, don't inline critical styles

* Fixed some downstream security issues

* Fixed a layout issue in series detail

* Fixed issue with btn-light not having background color. Updated layout for series detail metadata

* Cleaned up nav search

* Laid out the organization for custom theme components. Update _inputs.scss with variable overrides and depending on theme, it will just work.

* Lots of theming work

* Added inputs to the theme page

* Login and input placeholder changes

- Fixed login screen centering issue on all devices
- Changed the format of the login screen
- Change the input placeholder color

* Added checkbox styles

* Refactored tagbadges and removed some ngdeep selectors

* Added nav bar component and refactored some styles into event widget

* Cleaned nav events again and made dedicated popover body

* Finished pagination component

* Fixed up some styles with buttons

* refactored dropdown component

* Update accordion component

* Refactored breadcrumbs and rating star. Fixed a missing style for cards

* Fixed some styling issues on person badge, added modal component, and some global styles

* Finished moving everything within dark to component files

* Fixed up filter buttons, move card styles into a component theme, fixed slider style

* Refactored library card and grouped typeahead

* Updated normal typeahead component and reduced amount of ngdeep selector

* Refactored grid breakpoints to be available by css variable, but it's hardcoded into the app

* Ensure breakpoints are defined per theme

* Fixed up some styling overrides and customization for nav links and alt button

* Removed some deep styles, moved css out of splash container and brough back labels for login page

* Finished css variable refactor

* Refactored all the theme variable definitions into files for each theme.

* Added back bootstrap overrides

* Added a note about bootstrap theme colors being not-possible to swap out at runtime

* Cleaned up some dead code

* Implemented the ability to set a custom theme on the site. Cleaned up misc code throughout.

* Additional changes

- Fixed nav where "kavita" was not hiding correctly on small viewports
- Fixed search bar to make the behavior more consistent
- Fixed accordion buttons
- Changed accordion buttons to be more responsive
- Added radio button colors
- Fixed radios on theme test page
- Changed login and reset password card layouts to be more consistent.
- Added primary color shade for when darker shading is needed.

* Built a basic site, allow the user to apply different themes, refactored nav service code out.

* Implemented the ability update a user's theme

* Added unit tests for Scan and Get Content in SiteThemeService.

* Fixed a bug in the login code and Pref code which wasn't joining on SiteTheme table. Wrote Unit tests and the UI component to manage current theme.

* Implemented scan so that it manages custom themes with unit tests

* Component updates

- Repositioning style ordering
- Adding indicator override
- Adding select styles

* SignlaR integration, some fixes when creating custom entities, one single migration. Just login functionality left.

* More ui updated

- Added .no-hover to prevent hover on elements where not needed
- Changed all selects I could find to appropriate class
- Changed up nav tabs to work more like bootstrap tabs than pills
- Added padding to top of some containers to make styles consistent
- Added ability to change navbar fontawesome icon colors
- removed some unecessary inline styling
- Changed radio button to appropriate class
- Toned down primate color, a bit too bright for dark theme.
- Added ability to change button fontawesome icon color

* nav-tab fix for series-detail

* Added themes folder to gitignore

* Adding card overlay

* Fixing up light theme

* Everything is done. Only bug is that color-scheme isn't being set properly from css variable.

* Checkboxes have pointer by default. Confirm/Confirm email use default (dark) theme by default

* Fixed an error where color-scheme wasn't reflecting correctly on themes on first load

* Fixed user preferences not available on login

* Changing dual radios to switches and color tweaks

* disabled primary APCA fix

* button APCA fixes

* Fixed some timing issues with first load and image service

* Fixed swiper issues from upgrade

* Changed themes to be scss files again and adjusted Seed code

* Migrated carousel to css variables. Fixed a broken animation for search.

* Cleaned up some backend smells

* Fixed white border outline on nav tabs, added some variables for header

* Nav bar has been css variable-ified

* Added some basic eink stuff to make the app useable

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

* Bump versions by dotnet-bump-version.

* Fix an issue for first time running theme code, theme will not be available (#1081)

* Bump versions by dotnet-bump-version.

* Theme Cleanup (#1089)

* Fixed e-ink theme not properly applying correctly

* Fixed some seed changes. Changed card checkboxes to use our themed ones

* Fixed recently added carousel not going to recently-added page

* Fixed an issue where no results found would show when searching for a library name

* Cleaned up list a bit, typeahead dropdown still needs work

* Added a TODO to streamline series-card component

* Removed ng-lazyload-image module since we don't use it. We use lazysizes

* Darken card on hover

* Fixing accordion focus style

* ux pass updates

- Fixed typeahead width
- Fixed changelog download buttons
- Fixed a select
- Fixed various input box-shadows
- Fixed all anchors to only have underline on hover
- Added navtab hover and active effects

* more ux pass

- Fixed spacing on theme cards
- Fixed some light theme issues
- Exposed text-muted-color for theme card subtitle color

* UX pass fixes

- Changed back to bright green for primary on dark theme
- Changed fa icon to black on e-ink

* Merged changelog component

* Fixed anchor buttons text decoration

* Changed nav tabs to have a background color instead of open active state

* When user is not authenticated, make sure we set default theme (dark)

* Cleanup on carousel

* Updated Users tab to use small buttons with icons to align with Library tab

* Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs

* Fixed collection detail posters not rendering

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

* Bump versions by dotnet-bump-version.

* Event Widget Update (#1098)

* Took care of some notes in the code

* Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary

* Moved Tag cleanup code into Scanner service. Added a SplitQuery to another heavy API. Refactored Scan loop to remove parallelism and use async instead.

* Lots of rework on the codebase to support detailed messages and easier management of message sending. Need to take a break on this work.

* Progress is being made, but slowly. Code is broken in this commit.

* Progress is being made, but slowly. Code is broken in this commit.

* Fixed merge issue

* Fixed unit tests

* CoverUpdate is now hooked into new ProgressEvent structure

* Refactored code to remove custom observables and have everything use standard messages$

* Refactored a ton of instances to NotificationProgressEvent style and tons of the UI to respect that too. UI is still a bit buggy, but wholistically the work is done.

* Working much better. Sometimes events come in too fast. Currently cover update progress doesn't display on UI

* Fixed unit tests

* Removed SignalREvent to minimize internal event types. Updated the UI to use progress bars. Finished SiteThemeService.

* Merged metadata refresh progress events and changed library scan events to merge cleaner in the UI

* Changed RefreshMetadataProgress to CoverUpdateProgress to reflect the event better.

* Theme Cleanup (#1089)

* Fixed e-ink theme not properly applying correctly

* Fixed some seed changes. Changed card checkboxes to use our themed ones

* Fixed recently added carousel not going to recently-added page

* Fixed an issue where no results found would show when searching for a library name

* Cleaned up list a bit, typeahead dropdown still needs work

* Added a TODO to streamline series-card component

* Removed ng-lazyload-image module since we don't use it. We use lazysizes

* Darken card on hover

* Fixing accordion focus style

* ux pass updates

- Fixed typeahead width
- Fixed changelog download buttons
- Fixed a select
- Fixed various input box-shadows
- Fixed all anchors to only have underline on hover
- Added navtab hover and active effects

* more ux pass

- Fixed spacing on theme cards
- Fixed some light theme issues
- Exposed text-muted-color for theme card subtitle color

* UX pass fixes

- Changed back to bright green for primary on dark theme
- Changed fa icon to black on e-ink

* Merged changelog component

* Fixed anchor buttons text decoration

* Changed nav tabs to have a background color instead of open active state

* When user is not authenticated, make sure we set default theme (dark)

* Cleanup on carousel

* Updated Users tab to use small buttons with icons to align with Library tab

* Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs

* Fixed collection detail posters not rendering

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

* Bump versions by dotnet-bump-version.

* Tweaked some of the emitting code

* Some css, but pretty bad. Robbie please save me

* Removed a todo

* styling update

* Only send filename on FileScanProgress

* Some console.log spam cleanup

* Various updates

* Show events widget activity based on activeEvents

* progress bar color updates

* Code cleanup

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

* Bump versions by dotnet-bump-version.

* Scanner event hub fix (#1099)

* Scanner event hub fix

- Fixed an issue where the scanner would error when adding a new series because the series didn't have a library name yet. (develop)

* Removing library.type

* Bump versions by dotnet-bump-version.

* Workflow update to add nightly versions (#1100)

# Changed
- Changed: Changed automated workflow to release individual nightly versions on dockerhub

* Bump versions by dotnet-bump-version.

* Updating GA to parse version (#1101)

* Bump versions by dotnet-bump-version.

* GA Fixes (#1103)

**Strictly Repo Changes**
# Fixed
- Fixed: Fixed an issue where patch version was not being added to docker tag.

* Bump versions by dotnet-bump-version.

* Fixed specials being misaligned (#1106)

# Fixed
- Fixed: Fixed issue with specials not being properly aligned (develop)

* Bump versions by dotnet-bump-version.

* Bugfix/ux pass 2 (#1107)

* Adding margin bottom to series detail tabs

* Styling tag badges with green on dark

- Added 3 new css vars

* Removing underline from readmore

* Fixing see more to be on one line

* adding gutter to see more

* Changing queue toasts to info

* adding api key tooltip

* Updating active accordion on user preference.

* Fixing search bar and close btn position

* Fixed a bug where entering book reader in dark mode then closing out, would leave you in a broken white state.

* Fixed broken wiki links

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

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Series Detail Refactor (#1118)

* Fixed a bug where reading list and collection's summary wouldn't render newlines

* Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests.

* Unit tests for CleanSpecialTitle

* Laid out foundation for testing major code in SeriesController.

* Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load.

* Removed old property from Volume

* Changed tagbadge font size to rem.

* Refactored some methods from SeriesController.cs into SeriesService.cs

* UpdateRating unit tested

* Wrote unit tests for SeriesDetail

* Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats.

* Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index.

* Some cleanup on the code

* DeleteMultipleSeries test is hard. Going to skip.

* Removed some debug code and make all tabs Books for Book library Type

* Bump versions by dotnet-bump-version.

* Tachiyomi Bugfix (#1119)

* Updated the dependencies for .NET 6.0.2

* Fixed a bad prev chapter logic where we would bleed into chapters from last volume instead of specials.

* Fixed the get prev chapter code to properly walk the order according to documentation and updated some bad test cases

* Bump versions by dotnet-bump-version.

* Series index fix (#1120)

* Series index fix

# Fixed
- Fixed: Fixed an issue where epub series with index = 0 would be hidden on series detail page.

* Removing unnecessary conditional

* Bump versions by dotnet-bump-version.

* Misc Bugfixes (#1123)

* Fixed a bug where ComicInfo Count can be a float and we threw a parse error.

* Fixed a bug in download bookmarks which didn't properly create the filepaths for copying. Refactored into a service with a unit test.

In Scanner, repull genres, people and tags between chunk saves to ensure no unique constraint issues.

* Fixed a bug where card detail layout wouldn't refresh the library name on the card between pages

* Fixed an issue where a check to scrolling page back to top was missing in manga reader

* Fixed a bug where cleaning up collection tags without Series was missing after editing a Series.

* Cleaned up the styles for cover chooser

* Added Regex support for "Series 001 (Digital) (somethingwith1234)" and removed support for "A Compendium of Ghosts - 031 - The Third Story_ Part 12" due to complexity in parsing.

* Fixed a miscommunication on how Tachiyomi needs the API MarkChaptersUntilAsRead implemented. Now 0 chapter volumes will be marked.

* Removed unneeded DI

* Bump versions by dotnet-bump-version.

* CopyFilesToDirectory will now allow for one duplicate copy over and put (2) (#1126)

* Bump versions by dotnet-bump-version.

* Stablize the Styles (#1128)

* Fixed a bug where adding multiple series to reading list would throw an error on UI, but it was successful.

* When a series has a reading list, we now show the connection on Series detail.

* Removed all baseurl code from UI and not-connected component since we no longer use it.

* Fixed tag badges not showing a border. Added last read time to the series detail page

* Fixed up error interceptor to remove no-connection code

* Changed implementation for series detail. Book libraries will never send chapters back. Volume 0 volumes will not be sent in volumes ever. Fixed up more renaming logic on books to send more accurate representations to the UI.

* Cleaned up the selected tab and tab display logic

* Fixed a bad where statement in reading lists for series

* Fixed up tab logic again

* Fixed a small margin on search backdrop

* Made badge expander button smaller to align with badges

* Fixed a few UIs due to .form-group and .form-row being removed

* Updated Theme component page to help with style testing

* Added more components to theme tester

* Cleaned up some styling

* Fixed opacity on search item hover

* Bump versions by dotnet-bump-version.

* Hacked in code so that we render an image instead of canvas for fit to screen to try out. (#1131)

* Bump versions by dotnet-bump-version.

* Metadata Editing from the UI! (#1135)

* Added the skeleton code for layout, hooked up Age Rating, Publication Status, and Tags

* Tweaked message of Scan service to Finished scan of to better indicate the total scan time

* Hooked in foundation for person typeaheads

* Fixed people not populating typeaheads on load

* For manga/comics, when parsing, set the SeriesSort from ComicInfo if it exists.

* Implemented the ability to override and create new genre tags. Code is ready to flush out the rest.

* Ability to update metadata from the UI is hooked up. Next is locking.

* Updated typeahead to allow for non-multiple usage. Implemented ability to update Language tag in Series Metadata.

* Fixed a bug in GetContinuePoint for a case where we have Volumes, Loose Leaf chapters and no read progress.

* Added ETag headers on Images to allow for better caching (bookmarks and images in manga reader)

* Built out UI code to show locked indication to user

* Implemented Series locking and refactored a lot of styles in typeahead to make the lock setting work, plus misc cleanup.

* Added locked properties to dtos. Updated typeahead loading indicator to not interfere with close button if present

* Hooked up locking flags in UI

* Integrated regular field locking/unlocking

* Removed some old code

* Prevent input group from wrapping

* Implemented some basic layout for metadata on volume/chapter card modal. Refactored out all metadata from Chapter object in terms of UI and put into a separate call to ensure speedy delivery and simplicity of code.

* Refactored code to hide covers section if not an admin

* Implemented ability to modify a chapter/volume cover from the detail modal

* Removed a few variables and change cover image modal

* Added bookmark to single chapter view

* Put a temp fix in for a ngb v12 z-index bug (reported). Bumped ngb to 12.0 stable and fixed some small rendering bugs

* loading buttons ftw

* Lots of cleanup, looks like the story is finished

* Changed action name from Info to Details

* Style tweaks

* Fixed an issue where Summary would assume it's locked due to a subscription firing on setting the model

* Fixed some misc bugs

* Code smells

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

* Bump versions by dotnet-bump-version.

* Manga Reader Refresh (#1137)

* Refactored manga reader to use a regular image element for all cases except for split page rendering

* Fixed a weird issue where ordering of routes broke redireciton in one case.

* Added comments to a lot of the enums and refactored READER_MODE to be ReaderMode and much more clearer on function.

* Added bookmark effect on image renderer

* Implemented keyboard shortcut modal

* Introduced the new layout mode into the manga reader, updated preferences, and updated bookmark to work for said functionality. Need to implement renderer now

* Hooked in ability to show double pages but all the css is broken. Committing for help from Robbie.

* Fixed an issue where Language tag in metadata edit wasn't being updated

* Fixed up some styling on mobile for edit series detail

* Some css fixes

* Hooked in ability to set background color on reader (not implemented in reader). Optimized some code in ArchiveService to avoid extra memory allocations.

* Hooked in background color, generated the migration

* Fixed a bug when paging to cover images, full height would be used instead of full-width for cover images

* New option in reader to show screen hints (on by default). You can disable in user preferences which will stop showing pagination overlay hints

* Lots of fixes for double rendering mode

* Bumped the amount of cached pages to 8

* Fixed an issue where dropdowns weren't being locked on form manipulation

* Bump versions by dotnet-bump-version.

* On Deck tweaks + Bugfixes (#1141)

* Tweaked the On deck to only look for series that have progress in past 30 days. This number is just to test it out, it will be configurable later. Tweaked the layout of the dashboard to remove a redundant section.

* Fixed a bug where archives with __MACOSX/ inside would break the reader during flattening.

* Fixed a bug where confirm service rejection should have resolved as false.

* Fixed an issue with checking if server is accessible with loopback and local ips

* Bump versions by dotnet-bump-version.

* Manga Reader Shakeout (#1142)

* Fixed a unit test in ArchiveService

* Image scaling fixes

* removing test

* Added new layout mode (enum only) and cleaned up manga reader and wrote extra documentation

* Aligned code with cleanup

* Adding reverse classes for manga reading

* Disable options for layout modes that doesn't make sense.

* Cleaned up manga reader menu items to link to preferences options directly

* Work in progress, but rendering the correct page numbers for double. Need to rework caching logic so we can use existing image objects

* Pagination logic is now properly increasing page number an extra when double layout mode

* I can't figure out cachedImages to work properly with double pages, but doing it in a way where it handles downloading the image (and etag cache) + rendering the url, seems to work really well

* Double original fix, also flex squish fix

* Implemented last page on double which will load next chapter.

Fixed a bug where if GetImage from ReaderController threw an error, the chapter directory would be emptied, but the folder itself wasn't deleted.

* Fixed a bad if for double manga

* double class fix

* Cleanup up some console.logs

* Adjusted the caching for images in a reading session so they cache for 2 mins

* fixing webtoon image issue

* Tweaked the caching of images to 10 mins for reading. Fixed a bug where after webtoon, single image layout would be selected. Tweaked logic for handling prev/next pages on chapter boundaries.

* Fixed an issue where 2nd page would be skipped

* Fixed an issue where 2nd page would be skipped

* Fixed a skip page issue

* Misc css fixes

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

* Bump versions by dotnet-bump-version.

* Misc Bugfixes and Cleanup (#1144)

* Moved libraryType into chapter info

* Fixed a bug where you could not reset cover on a series

* Patched in relevant changes from another polish branch

* Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker.

* Refactored invite user to always print the url to setup a new account.

* Single page renderer uses canvasImage rather than re-requesting and relying on cache

* Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image

* Fixed a rendering bug with split image functionality

* Added title to copy button

* Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter.

Refactored 2 methods from controller into service and unit tested.

* Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened.

* Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal.

* Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update.

* Fixed up some poor documentation on FilterDto.

* Bump versions by dotnet-bump-version.

* Bugfixes and Cover Chooser Upgrades (#1146)

* Fixed a bug where GetNextChapter would return a loose leaf chapter from a special when it should return nothing.

* Fixed a bug in events widget when an update comes in after a user refreshes, the active event counter could get out of sync, thus showing "Nothing going on here"

Refactored the events widget to be named appropriately.

* Refactored code to have errors during threaded tasks propagate to the UI via events widget (css still needed).

Removed ScanLibraryError in favor of generic Error event.

* Fixed up some code and added ability to remove the event from events widget

* Fixed a bug where modifiying certain fields, like summary, wouldn't lock the field

* Fixed a few bugs where lock state was not being set in the DB correctly nor were certain combinations of locking fields and editing fields.

* Removed debug code

* Updated the discord alert to tag new group

* Refactored cover upload to actually handle uploading a temp file via url on the backend so that users can user change cover by url. Fixed up some bugs that occured when chaning the image container in a previous PR.

* Code cleanup

* Cleaned up the css on the error items

* Code cleanup

* Bump versions by dotnet-bump-version.

* Side nav (#1155)

* adding back side-nav

* Event Widget Update (#1098)

* Took care of some notes in the code

* Fixed an issue where Extra might get flagged as special too early, if in a word like Extraordinary

* Moved Tag cleanup code into Scanner service. Added a SplitQuery to another heavy API. Refactored Scan loop to remove parallelism and use async instead.

* Lots of rework on the codebase to support detailed messages and easier management of message sending. Need to take a break on this work.

* Progress is being made, but slowly. Code is broken in this commit.

* Progress is being made, but slowly. Code is broken in this commit.

* Fixed merge issue

* Fixed unit tests

* CoverUpdate is now hooked into new ProgressEvent structure

* Refactored code to remove custom observables and have everything use standard messages$

* Refactored a ton of instances to NotificationProgressEvent style and tons of the UI to respect that too. UI is still a bit buggy, but wholistically the work is done.

* Working much better. Sometimes events come in too fast. Currently cover update progress doesn't display on UI

* Fixed unit tests

* Removed SignalREvent to minimize internal event types. Updated the UI to use progress bars. Finished SiteThemeService.

* Merged metadata refresh progress events and changed library scan events to merge cleaner in the UI

* Changed RefreshMetadataProgress to CoverUpdateProgress to reflect the event better.

* Theme Cleanup (#1089)

* Fixed e-ink theme not properly applying correctly

* Fixed some seed changes. Changed card checkboxes to use our themed ones

* Fixed recently added carousel not going to recently-added page

* Fixed an issue where no results found would show when searching for a library name

* Cleaned up list a bit, typeahead dropdown still needs work

* Added a TODO to streamline series-card component

* Removed ng-lazyload-image module since we don't use it. We use lazysizes

* Darken card on hover

* Fixing accordion focus style

* ux pass updates

- Fixed typeahead width
- Fixed changelog download buttons
- Fixed a select
- Fixed various input box-shadows
- Fixed all anchors to only have underline on hover
- Added navtab hover and active effects

* more ux pass

- Fixed spacing on theme cards
- Fixed some light theme issues
- Exposed text-muted-color for theme card subtitle color

* UX pass fixes

- Changed back to bright green for primary on dark theme
- Changed fa icon to black on e-ink

* Merged changelog component

* Fixed anchor buttons text decoration

* Changed nav tabs to have a background color instead of open active state

* When user is not authenticated, make sure we set default theme (dark)

* Cleanup on carousel

* Updated Users tab to use small buttons with icons to align with Library tab

* Cleaned up brand to not underline, removed default link underline on hover in dropdown and pill tabs

* Fixed collection detail posters not rendering

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

* Bump versions by dotnet-bump-version.

* Tweaked some of the emitting code

* Some css, but pretty bad. Robbie please save me

* Removed a todo

* styling update

* Only send filename on FileScanProgress

* Some console.log spam cleanup

* Various updates

* Show events widget activity based on activeEvents

* progress bar color updates

* Code cleanup

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

* Bump versions by dotnet-bump-version.

* Scanner event hub fix (#1099)

* Scanner event hub fix

- Fixed an issue where the scanner would error when adding a new series because the series didn't have a library name yet. (develop)

* Removing library.type

* Bump versions by dotnet-bump-version.

* Workflow update to add nightly versions (#1100)

# Changed
- Changed: Changed automated workflow to release individual nightly versions on dockerhub

* Bump versions by dotnet-bump-version.

* Updating GA to parse version (#1101)

* Bump versions by dotnet-bump-version.

* GA Fixes (#1103)

**Strictly Repo Changes**
# Fixed
- Fixed: Fixed an issue where patch version was not being added to docker tag.

* Bump versions by dotnet-bump-version.

* Fixed specials being misaligned (#1106)

# Fixed
- Fixed: Fixed issue with specials not being properly aligned (develop)

* Bump versions by dotnet-bump-version.

* Bugfix/ux pass 2 (#1107)

* Adding margin bottom to series detail tabs

* Styling tag badges with green on dark

- Added 3 new css vars

* Removing underline from readmore

* Fixing see more to be on one line

* adding gutter to see more

* Changing queue toasts to info

* adding api key tooltip

* Updating active accordion on user preference.

* Fixing search bar and close btn position

* Fixed a bug where entering book reader in dark mode then closing out, would leave you in a broken white state.

* Fixed broken wiki links

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

* Bump versions by dotnet-bump-version.

* Series Detail Refactor (#1118)

* Fixed a bug where reading list and collection's summary wouldn't render newlines

* Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests.

* Unit tests for CleanSpecialTitle

* Laid out foundation for testing major code in SeriesController.

* Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load.

* Removed old property from Volume

* Changed tagbadge font size to rem.

* Refactored some methods from SeriesController.cs into SeriesService.cs

* UpdateRating unit tested

* Wrote unit tests for SeriesDetail

* Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats.

* Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index.

* Some cleanup on the code

* DeleteMultipleSeries test is hard. Going to skip.

* Removed some debug code and make all tabs Books for Book library Type

* Bump versions by dotnet-bump-version.

* Tachiyomi Bugfix (#1119)

* Updated the dependencies for .NET 6.0.2

* Fixed a bad prev chapter logic where we would bleed into chapters from last volume instead of specials.

* Fixed the get prev chapter code to properly walk the order according to documentation and updated some bad test cases

* Updated side nav to float a bit and added user settings to it.

* Refactored the code to hide/show sidenav to be more angular and decoupled

* Moved Changelog out of admin dashboard and into a dedicated page in user menu. Added a wiki link from user menu

* Introduced a side nav item for rendering each item and refactored code to use it.

* Added a filter of side nav when there are more than 10 libraries. Added some themeing overrides for side nav.

* Cleaned up the template code for side nav item so if there is no link, we don't generate that html directive

* Refactored side nav into a module and migrated a few pipes into a pipe module for easy re-use

* Added companion bar on reading list and collection. Updated modules to load pages and make side nav items clickable as anchors, so new tab works.

* Moved metadata filter into separate component/module and the button in the companion bar. Needs cleanup.

* Finished cleanup and refactoring of metadata filter into separate component.

Removed filtering from Collections as it doesn't work and wasn't hooked up.

* Tweaked the css on carousel component

* Added to library detail and series-detail

* Fixes and css vars

* Stop destroying sidenav, animaton timing

* Integrated side nav on the rest of the pages

* Navbar now collapses to icons

* mobile sidenav start

* more mobile fixes

* mobile tweaks

* light and e-ink theme updates

* white and eink dropdown color fixes

* plex inspired side-nav

* theme fixes

* Making spacing more uniform across app

* More fixes

* fixing spacing on cards

* actionable fix for sidenav

* no scroll on mobile when sidenav is open

* hide sidenav on pages

* Adding card spacing

* Adding ability to remove sidenav when in a reader

* tidying up sidenav toggles

* side-nav mobile updates

* fixing up other themes

* overlay fixes

* Cleaned up the code to make the observables have better names.

Removed a bunch of pointless subscriptions. Cleaned up methods that werent needed.

Added jsdocs to help ensure the understandability of the 2 states for the side nav.

* Integrated a highlight effect on side nav. Fixed a ton of places where the nav was being hidden when it shouldn't.

* Fixed where active state wasn't working on all urls

* misc fixes

- smaller hamburger
- z-index fixes
- active fixes

* Revert "Merge branch 'develop' into feature/side-nav-upgrade"

This reverts commit 76b0d15a98, reversing
changes made to b3ed553954.

* Fixing edit-series modal spacing

* Give the ability to jump to a library from admin manage libraries page

* Fixed a bug with highlighting active item on side nav

* Moved localized series title to companion bar via subtitle

* Removed old title

* Fixed a bug where clicking a link would reload the whole app, styling fixes on filter, fixed issue with initial load not setting active state, adjusted styles on active style.

* code cleanup

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

* Bump versions by dotnet-bump-version.

* Fixed a bug where companion bar would be pushing content to the right even when not visible. Updated nav service localstorage key. (#1159)

* Bump versions by dotnet-bump-version.

* Side Nav Fixes (#1161)

* Fixed an issue where there was extra padding on top/bottom of readers when side nav was hidden.

* Fixed a bug where fit to screen wasn't forcing width scaling

* Added back total pages to many pages

* Fixed the padding on series detail cards

* Tweaked carousels to match series detail padding

* Fixed an issue where large amount of libraries could have 2 highlighted at once due to how highlight logic works on routes.

* Cleaned up some extra space in card detail layout due to moving title into compainion bar

* Moved some gloabls to global and moved color-scheme to body tag

* Moved scrollbar onto the body itself which helps with page jank on loading and fixes scrollbar not working with theme

* Bump versions by dotnet-bump-version.

* Fixed loose chapters marked as read for Tachiyomi (#1158)

* Tachiyomi-related fixes

* Created unit test for MarkAsReadAnythingUntil

* Applied the requested changes.

* More Bugfixes from Side Nav (#1162)

* Fixed a bug where the bottom of the page could be cut off

* Adjusted all the headings to h2, which looks better

* Refactored GetSeriesDetail to actually map the names inside the code so the UI just displays.

* Put in some basic improvements to OPDS by using Series Detail type layout, but this only reduces one click.

* Fixed a bug where offset from scrollbar fix causes readers to be cutoff.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* OPDS Rework (#1164)

* Fixed a bug where the bottom of the page could be cut off

* Adjusted all the headings to h2, which looks better

* Refactored GetSeriesDetail to actually map the names inside the code so the UI just displays.

* Put in some basic improvements to OPDS by using Series Detail type layout, but this only reduces one click.

* Fixed a bug where offset from scrollbar fix causes readers to be cutoff.

* Ensure the hamburger menu icon is aligned with side nav

* Disable the image splitting dropdown in webtoon mode

* Fixed broken progress/scroll code as we scroll on the body instead of window now

* Fixed phone-hidden class not working due to a bad media query

* Lots of changes to OPDS to provide a richer text experience. Uses Issues or Books based on library type. Cleans up the experience by providing Storyline from the get-go.

* Updated OPDS-SE search description to include collections and reading lists.

* Fixed up some title stuff

* If a volume only has one file underneath it, flatten it and send a chapter as if it were the volume.

* Code cleanup

* Bump versions by dotnet-bump-version.

* Feature/enhancements and more (#1166)

* Moved libraryType into chapter info

* Fixed a bug where you could not reset cover on a series

* Patched in relevant changes from another polish branch

* Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker.

* Refactored invite user to always print the url to setup a new account.

* Single page renderer uses canvasImage rather than re-requesting and relying on cache

* Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image

* Fixed a rendering bug with split image functionality

* Added title to copy button

* Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter.

Refactored 2 methods from controller into service and unit tested.

* Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened.

* Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal.

* Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update.

* Fixed up some poor documentation on FilterDto.

* Some string equals enhancements to reduce extra allocations

* Fixed an issue when trying to download via a url, to remove query parameters to get the format

* Made an optimization to Normalize method to reduce memory pressure by 100MB over the course of a scan (16k files)

* Adjusted the styles on dashboard for first time setup and used a routerlink rather than href to avoid a fresh load.

* Use framgment on router link

* Hooked in the ability to search by release year (along with series optionally) and series will be returned back.

* Fixed a bug in the filter format code where it was sending the wrong type

* Only show clear all on typeahead when there are at least one selected item

* Cleaned up the styles of the styles of the typeahead

* Removed some dead code

* Implemented the ability to filter against a series name.

* Fixed filter top offset

* Ensure that when we add or remove libraries, the side nav of users gets updated.

* Tweaked the width on the mobile side nav

* Close side nav on clicking overlay on mobile viewport

* Don't show a pointer if the carousel section title is not actually selectable

* Removed the User profile on the side nav so home is always first. Tweaked styles to match

* Fixed up some poor documentation on FilterDto.

* Fixed a bug where Latest read date wasn't being set due to an early short circuit.

* When sending the chapter file, format the title of the FeedEntry more like Series Detail.

* Removed dead code

* Bump versions by dotnet-bump-version.

* Bugfixes  (#1177)

* Fixed an underline on hover of pagination link

* Ensure title of companion bar eats full width if there is no filter

* If a user doesn't have the Download role, they will not be able to download over OPDS.

* Fixed a bug where after going into webtoon reader mode then leaving, the bookmark effect would continue using the webtoon mode styling

* Fixed a bug where continuous reader wasn't being triggered due to moving scrollbar to body and a floating point percision error on scroll top

* Fixed how continuous trigger is shown so that we properly adjust scroll on the top (for prev chapter)

* Fixed a bad merge that broke saving any edits to series metadata

* When a epub key is not correct, even after we correct it, ignore the inlining of the style so the book is at least still readable.

* Disabled double rendering (this feature is being postponed to a later release)

* Disabled user setting and forced it to Single on any save

* Removed cache directory from UpdateSettings validation as we don't allow changing it.

* Fix security issue with url parse

* After all migrations run, update the installed version in the Database. Send that installed version on the stat service.

* Dependency bot to update some security stuff

* Some misc code cleanup and fixes on the typeahead (still broken)

* Bump versions by dotnet-bump-version.

* New Feature Stats (#1179)

* When searching, search against normalized names.

* Added new stat fields

* Bump versions by dotnet-bump-version.

* Getting Ready for Release (#1180)

* One more unit test for Tachiyomi

* Removed some debug code in the manga reader menu

* Fixed a typeahead bug where using Enter on add new item or selected options could cause items to disappear from selected state or other visual glitches

* Actually fix the selection issue. We needed to filter out selected before we access element

* Cleaned up collection detail page to align to new side nav design

* Cleaned up some styling on the reading list page

* Fixed a bug where side nav would not be visible on the main app due to some weird redirect logic

* Fixed a bug where when paging to the last page, a page will be skipped and user will have to refresh manually to view

* Fixed some styling bugs on drawer for light themes. Added missing pagination colors on light themes

* On mobile screens, add some padding on series-detail page

* Fixed a bad test case helper

* Bump versions by dotnet-bump-version.

* Release Shakeout Part 1 (#1184)

* Have actionables on series detail action bar and in title to make it easier to use.

* Fixed a bug where super long titles could render over the book content

* Fixed a bug in get continue point where it wasn't working in an edge case

* Bump versions by dotnet-bump-version.

* Release Shakeout (#1186)

* Cleaned up some styles on the progress bar in book reader

* Fixed up some phone-hidden classes and added titles around the codebase. Stat reporting on first run now takes into account that admin user wont exist.

* Fixed manage library page not updating last scan time when a notification event comes in.

* Integrated SeriesSort ComicInfo tag (somehow it got missed)

* Some minor style changes and no results found for bookmarks on chapter detail modal

* Fixed the labels in action bar on book reader so Prev/Next are in same place

* Cleaned up some responsive styles around images and reduced custom classes in light of new display classes on collection detail and series detail pages

* Fixed an issue with webkit browsers and book reader where the scroll to would fail as the document wasn't fully rendered. A 10ms delay seems to fix the issue.

* Cleaned up some code and filtering for collections. Collection detail is missing filtering functionality somehow, disabled the button and will add in future release

* Correctly validate and show a message when a user is not an admin or has change password role when going through forget password flow.

* Fixed a bug on manage libraries where library last scan didn't work on first scan of a library, due to there being no updated series.

* Fixed a rendering issue with text being focused on confirm email page textboxes. Fixed a bug where when deleting a theme that was default, Kavita didn't reset Dark as the default theme.

* Cleaned up the naming and styles for side nav active item hover

* Fixed event widget to have correct styling on eink and light

* Tried to fix a rendering issue on side nav for light themes, but can't figure it out

* On light more, ensure switches are green

* Fixed a bug where opening a page with a preselected filter, the filter toggle button would require 2 clicks to collapse

* Reverted the revert of On Deck.

* Improved the upload by url experience by sending a custom fail error to UI when a url returns 401.

* When deleting a library, emit a series removed event for each series removed so user's dashboards/screens update.

* Fixed an api throwing an error due to text being sent back instead of json.

* Fixed a refresh bug with refreshing pending invites after deleting an invite. Ensure we always refresh pending invites even if user cancel's from invite, as they might invite, then hit cancel, where invite is still active.

* Fixed a bug where invited users with + in the email would fail due to validation, but UI wouldn't properly inform user.

* Bump versions by dotnet-bump-version.

* Version bump for release

Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-04-02 12:11:07 -05:00 committed by GitHub
parent 2193451dfb
commit 8aced68e5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
421 changed files with 23220 additions and 13868 deletions

View file

@ -3,7 +3,6 @@ import { CanActivate } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@Injectable({
@ -15,8 +14,8 @@ export class AdminGuard implements CanActivate {
canActivate(): Observable<boolean> {
// this automaticallys subs due to being router guard
return this.accountService.currentUser$.pipe(take(1),
map((user: User) => {
if (this.accountService.hasAdminRole(user)) {
map((user) => {
if (user && this.accountService.hasAdminRole(user)) {
return true;
}

View file

@ -3,7 +3,6 @@ import { CanActivate, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@Injectable({
@ -15,7 +14,7 @@ export class AuthGuard implements CanActivate {
canActivate(): Observable<boolean> {
return this.accountService.currentUser$.pipe(take(1),
map((user: User) => {
map((user) => {
if (user) {
return true;
}

View file

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

View file

@ -1,4 +1,4 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
@ -8,14 +8,12 @@ import {
import { Observable, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { catchError, take } from 'rxjs/operators';
import { catchError } from 'rxjs/operators';
import { AccountService } from '../_services/account.service';
import { environment } from 'src/environments/environment';
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
public urlKey: string = 'kavita--no-connection-url';
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
@ -44,12 +42,6 @@ export class ErrorInterceptor implements HttpInterceptor {
if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') {
this.toastr.error('Something unexpected went wrong.');
}
// If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there
// if (this.router.url !== '/no-connection') {
// localStorage.setItem(this.urlKey, this.router.url);
// this.router.navigateByUrl('/no-connection');
// }
break;
}
return throwError(error);
@ -126,8 +118,7 @@ export class ErrorInterceptor implements HttpInterceptor {
private handleAuthError(error: any) {
// NOTE: Signin has error.error or error.statusText available.
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
this.accountService.logout();
});
this.accountService.logout();
this.router.navigateByUrl('/login');
}
}

View file

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

View file

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

View file

@ -1,16 +1,37 @@
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 ChapterMetadata {
id: number;
chapterId: number;
title: string;
year: string;
ageRating: AgeRating;
releaseDate: string;
language: string;
publicationStatus: PublicationStatus;
summary: string;
count: number;
totalCount: number;
genres: Array<Genre>;
tags: Array<Tag>;
writers: Array<Person>;
penciller: Array<Person>;
inker: Array<Person>;
colorist: Array<Person>;
letterer: Array<Person>;
coverArtist: Array<Person>;
editor: 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>;
}

View file

@ -1,7 +1,8 @@
import { MangaFile } from './manga-file';
import { Person } from './person';
import { Tag } from './tag';
/**
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
*/
export interface Chapter {
id: number;
range: string;
@ -18,19 +19,8 @@ export interface Chapter {
isSpecial: boolean;
title: string;
created: string;
titleName: string;
/**
* This is only Year and Month, Day is not supported from underlying sources
* Actual name of the Chapter if populated in underlying metadata
*/
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>;
titleName: string;
}

View file

@ -1,10 +1,10 @@
/**
* This is for base url only. Not to be used my applicaiton, only loading and bootstrapping app
*/
export class ConfigData {
baseUrl: string = '/';
// export class ConfigData {
// baseUrl: string = '/';
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
}
// constructor(baseUrl: string) {
// this.baseUrl = baseUrl;
// }
// }

View file

@ -0,0 +1,32 @@
import { EVENTS } from "src/app/_services/message-hub.service";
export interface ErrorEvent {
/**
* Payload of the event subtype
*/
body: any;
/**
* Subtype event
*/
name: EVENTS.Error;
/**
* Title to display in events widget
*/
title: string;
/**
* Optional subtitle to display. Defaults to empty string
*/
subTitle: string;
/**
* Type of event. Helps events widget to understand how to handle said event
*/
eventType: 'single';
/**
* Type of progress. Helps widget understand how to display spinner
*/
progress: 'none';
/**
* When event was sent
*/
eventTime: string;
}

View file

@ -0,0 +1,12 @@
/**
* Represents a file being scanned during a Library Scan
*/
export interface FileScanProgressEvent {
// libraryId: number;
// libraryName: string;
// fileName: string;
title: string;
subtitle: string;
eventTime: string;
}

View file

@ -0,0 +1,4 @@
export interface LibraryModifiedEvent {
libraryId: number;
action: 'create' | 'delelte';
}

View file

@ -0,0 +1,40 @@
export interface NotificationContainer<T> {
/**
* Represents underlying type of event
*/
type: string;
/**
* How many events are in this object
*/
size: number;
events: Array<T>;
}
export interface ActivityNotification {
type: string; // library.update.section
/**
* If this notification has some sort of cancellable operation
*/
cancellable: boolean;
userId: number;
/**
* Main action title ie) Scanning LIBRARY_NAME
*/
title: string;
/**
* Detail information about action. ie) Series Name
*/
subtitle: string;
/**
* Progress of this action [0-100]
*/
progress: number;
/**
* Any additional context backend needs to send to UI
*/
context: {
libraryId: number;
};
}

View file

@ -0,0 +1,30 @@
export interface NotificationProgressEvent {
/**
* Payload of the event subtype
*/
body: any;
/**
* Subtype event
*/
name: string;
/**
* Title to display in events widget
*/
title: string;
/**
* Optional subtitle to display. Defaults to empty string
*/
subTitle: string;
/**
* Type of event. Helps events widget to understand how to handle said event
*/
eventType: 'single' | 'started' | 'updated' | 'ended';
/**
* Type of progress. Helps widget understand how to display spinner
*/
progress: 'none' | 'indeterminate' | 'determinate';
/**
* When event was sent
*/
eventTime: string;
}

View file

@ -2,4 +2,10 @@ export interface ProgressEvent {
libraryId: number;
progress: number;
eventTime: string;
// New fields
/**
* Event type
*/
name: string;
}

View file

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

View file

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

View file

@ -0,0 +1,3 @@
export interface SiteThemeProgressEvent {
themeName: string;
}

View file

@ -0,0 +1,10 @@
export interface InviteUserResponse {
/**
* Link to register new user
*/
emailLink: string;
/**
* If an email was sent to the invited user
*/
emailSent: boolean;
}

View file

@ -1,16 +1,21 @@
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
import { PageSplitOption } from './page-split-option';
import { READER_MODE } from './reader-mode';
import { ReaderMode } from './reader-mode';
import { ReadingDirection } from './reading-direction';
import { ScalingOption } from './scaling-option';
import { SiteTheme } from './site-theme';
export interface Preferences {
// Manga Reader
readingDirection: ReadingDirection;
scalingOption: ScalingOption;
pageSplitOption: PageSplitOption;
readerMode: READER_MODE;
readerMode: ReaderMode;
autoCloseMenu: boolean;
layoutMode: LayoutMode;
backgroundColor: string;
showScreenHints: boolean;
// Book Reader
bookReaderDarkMode: boolean;
@ -22,10 +27,11 @@ export interface Preferences {
bookReaderReadingDirection: ReadingDirection;
// Global
siteDarkMode: boolean;
theme: SiteTheme;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}];
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}];

View file

@ -1,14 +1,17 @@
export enum READER_MODE {
/**
* The pagination method used by the reader
*/
export enum ReaderMode {
/**
* Manga default left/right to page
*/
MANGA_LR = 0,
LeftRight = 0,
/**
* Manga up and down to page
*/
MANGA_UD = 1,
UpDown = 1,
/**
* Webtoon reading (scroll) with optional areas to tap
*/
WEBTOON = 2
Webtoon = 2
}

View file

@ -1,3 +1,6 @@
/**
* Direction the user is reading. Maps to the pagination method. Not applicable with ReaderMode.Webtoon
*/
export enum ReadingDirection {
LeftToRight = 0,
RightToLeft = 1

View file

@ -1,6 +1,21 @@
/**
* How the image should scale to the screen size
*/
export enum ScalingOption {
/**
* Fit the image into the height of screen
*/
FitToHeight = 0,
/**
* Fit the image into the width of screen
*/
FitToWidth = 1,
/**
* Apply no logic and render the image as is
*/
Original = 2,
/**
* Ask the reader to attempt to choose the best ScalingOption for the user
*/
Automatic = 3
}

View file

@ -0,0 +1,22 @@
/**
* Where does the theme come from
*/
export enum ThemeProvider {
System = 1,
User = 2
}
/**
* Theme for the whole instance
*/
export interface SiteTheme {
id: number;
name: string;
filePath: string;
isDefault: boolean;
provider: ThemeProvider;
/**
* The actual class the root is defined against. It is generated at the backend.
*/
selector: string;
}

View file

@ -0,0 +1,12 @@
import { Chapter } from "../chapter";
import { Volume } from "../volume";
/**
* This is built for Series Detail itself
*/
export interface SeriesDetail {
specials: Array<Chapter>;
chapters: Array<Chapter>;
volumes: Array<Volume>;
storylineChapters: Array<Chapter>;
}

View file

@ -29,6 +29,7 @@ export interface SeriesFilter {
tags: Array<number>;
languages: Array<string>;
publicationStatus: Array<number>;
seriesNameQuery: string;
}
export interface SortOptions {

View file

@ -6,11 +6,12 @@ import { Person } from "./person";
import { Tag } from "./tag";
export interface SeriesMetadata {
publisher: string;
seriesId: number;
summary: string;
collectionTags: Array<CollectionTag>;
genres: Array<Genre>;
tags: Array<Tag>;
collectionTags: Array<CollectionTag>;
writers: Array<Person>;
coverArtists: Array<Person>;
publishers: Array<Person>;
@ -24,6 +25,23 @@ export interface SeriesMetadata {
ageRating: AgeRating;
releaseYear: number;
language: string;
seriesId: number;
publicationStatus: PublicationStatus;
summaryLocked: boolean;
genresLocked: boolean;
tagsLocked: boolean;
writersLocked: boolean;
coverArtistsLocked: boolean;
publishersLocked: boolean;
charactersLocked: boolean;
pencillersLocked: boolean;
inkersLocked: boolean;
coloristsLocked: boolean;
letterersLocked: boolean;
editorsLocked: boolean;
translatorsLocked: boolean;
ageRatingLocked: boolean;
releaseYearLocked: boolean;
languageLocked: boolean;
publicationStatusLocked: boolean;
}

View file

@ -4,16 +4,44 @@ import { Volume } from './volume';
export interface Series {
id: number;
name: string;
originalName: string; // This is not shown to user
/**
* This is not shown to user
*/
originalName: string;
localizedName: string;
sortName: string;
coverImageLocked: boolean;
sortNameLocked: boolean;
localizedNameLocked: boolean;
nameLocked: boolean;
volumes: Volume[];
pages: number; // Total pages in series
pagesRead: number; // Total pages the logged in user has read
userRating: number; // User rating
userReview: string; // User review
/**
* Total pages in series
*/
pages: number;
/**
* Total pages the logged in user has read
*/
pagesRead: number;
/**
* User's rating (0-5)
*/
userRating: number;
/**
* The user's review
*/
userReview: string;
libraryId: number;
created: string; // DateTime when entity was created
/**
* DateTime the entity was created
*/
created: string;
/**
* Format of the Series
*/
format: MangaFormat;
/**
* DateTime that represents last time the logged in user read this series
*/
latestReadDate: string;
}

View file

@ -4,10 +4,9 @@ export interface Volume {
id: number;
number: number;
name: string;
coverImage: string;
created: string;
lastModified: string;
pages: number;
pagesRead: number;
chapters?: Array<Chapter>;
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
}

View file

@ -7,6 +7,8 @@ import { Preferences } from '../_models/preferences/preferences';
import { User } from '../_models/user';
import { Router } from '@angular/router';
import { MessageHubService } from './message-hub.service';
import { ThemeService } from '../theme.service';
import { InviteUserResponse } from '../_models/invite-user-response';
@Injectable({
providedIn: 'root'
@ -19,7 +21,7 @@ export class AccountService implements OnDestroy {
currentUser: User | undefined;
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User>(1);
private currentUserSource = new ReplaySubject<User | undefined>(1);
currentUser$ = this.currentUserSource.asObservable();
/**
@ -30,7 +32,7 @@ export class AccountService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(private httpClient: HttpClient, private router: Router,
private messageHub: MessageHubService) {}
private messageHub: MessageHubService, private themeService: ThemeService) {}
ngOnDestroy(): void {
this.onDestroy.next();
@ -74,6 +76,13 @@ export class AccountService implements OnDestroy {
localStorage.setItem(this.userKey, JSON.stringify(user));
localStorage.setItem(this.lastLoginKey, user.username);
if (user.preferences && user.preferences.theme) {
this.themeService.setTheme(user.preferences.theme.name);
} else {
this.themeService.setTheme(this.themeService.defaultTheme);
}
} else {
this.themeService.setTheme(this.themeService.defaultTheme);
}
this.currentUserSource.next(user);
@ -122,8 +131,8 @@ export class AccountService implements OnDestroy {
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
}
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, sendEmail: boolean}) {
return this.httpClient.post<string>(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'});
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>}) {
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/invite', model);
}
confirmEmail(model: {email: string, username: string, password: string, token: string}) {
@ -150,6 +159,20 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns
*/
getPreferences() {
return this.httpClient.get<Preferences>(this.baseUrl + 'users/get-preferences').pipe(map(pref => {
if (this.currentUser !== undefined || this.currentUser != null) {
this.currentUser.preferences = pref;
this.setCurrentUser(this.currentUser);
}
return pref;
}), takeUntil(this.onDestroy));
}
updatePreferences(userPreferences: Preferences) {
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
if (this.currentUser !== undefined || this.currentUser != null) {

View file

@ -121,7 +121,7 @@ export class ActionFactoryService {
this.chapterActions.push({
action: Action.Edit,
title: 'Info',
title: 'Details',
callback: this.dummyCallback,
requiresAdmin: false
});
@ -247,7 +247,7 @@ export class ActionFactoryService {
},
{
action: Action.Edit,
title: 'Info',
title: 'Details',
callback: this.dummyCallback,
requiresAdmin: false
}

View file

@ -58,7 +58,7 @@ export class ActionService implements OnDestroy {
return;
}
this.libraryService.scan(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Scan queued for ' + library.name);
this.toastr.info('Scan queued for ' + library.name);
if (callback) {
callback(library);
}
@ -84,7 +84,7 @@ export class ActionService implements OnDestroy {
}
this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Scan queued for ' + library.name);
this.toastr.info('Scan queued for ' + library.name);
if (callback) {
callback(library);
}
@ -128,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 queued for ' + series.name);
this.toastr.info('Scan queued for ' + series.name);
if (callback) {
callback(series);
}
@ -149,7 +149,7 @@ export class ActionService implements OnDestroy {
}
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
this.toastr.success('Refresh covers queued for ' + series.name);
this.toastr.info('Refresh covers queued for ' + series.name);
if (callback) {
callback(series);
}

View file

@ -1,7 +1,9 @@
import { Injectable, OnDestroy } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { ThemeService } from '../theme.service';
import { RecentlyAddedItem } from '../_models/recently-added-item';
import { AccountService } from './account.service';
import { NavService } from './nav.service';
@ -19,9 +21,9 @@ export class ImageService implements OnDestroy {
private onDestroy: Subject<void> = new Subject();
constructor(private navSerivce: NavService, private accountService: AccountService) {
this.navSerivce.darkMode$.subscribe(res => {
if (res) {
constructor(private accountService: AccountService, private themeService: ThemeService) {
this.themeService.currentTheme$.pipe(takeUntil(this.onDestroy)).subscribe(theme => {
if (this.themeService.isDarkTheme()) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
} else {
@ -81,6 +83,10 @@ export class ImageService implements OnDestroy {
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
}
getCoverUploadImage(filename: string) {
return this.baseUrl + 'image/cover-upload?filename=' + encodeURIComponent(filename);
}
updateErroredImage(event: any) {
event.target.src = this.placeholderImage;
}

View file

@ -1,34 +1,59 @@
import { EventEmitter, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { LibraryModifiedEvent } from '../_models/events/library-modified-event';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event';
import { User } from '../_models/user';
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
ScanSeries = 'ScanSeries',
RefreshMetadataProgress = 'RefreshMetadataProgress',
SeriesAdded = 'SeriesAdded',
SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection',
ScanLibraryError = 'ScanLibraryError',
/**
* A generic error that occurs during operations on the server
*/
Error = 'Error',
BackupDatabaseProgress = 'BackupDatabaseProgress',
/**
* A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources
*/
CleanupProgress = 'CleanupProgress',
/**
* A subtype of NotificationProgress that represnts a user downloading a file or group of files
*/
DownloadProgress = 'DownloadProgress',
/**
* A generic progress event
*/
NotificationProgress = 'NotificationProgress',
/**
* A subtype of NotificationProgress that represents the underlying file being processed during a scan
*/
FileScanProgress = 'FileScanProgress',
/**
* A custom user site theme is added or removed during a scan
*/
SiteThemeProgress = 'SiteThemeProgress',
/**
* A cover is updated
*/
CoverUpdate = 'CoverUpdate'
CoverUpdate = 'CoverUpdate',
/**
* A subtype of NotificationProgress that represents a file being processed for cover image extraction
*/
CoverUpdateProgress = 'CoverUpdateProgress',
/**
* A library is created or removed from the instance
*/
LibraryModified = 'LibraryModified'
}
export interface Message<T> {
@ -36,6 +61,7 @@ export interface Message<T> {
payload: T;
}
@Injectable({
providedIn: 'root'
})
@ -44,19 +70,36 @@ export class MessageHubService {
private hubConnection!: HubConnection;
private messagesSource = new ReplaySubject<Message<any>>(1);
public messages$ = this.messagesSource.asObservable();
private onlineUsersSource = new BehaviorSubject<string[]>([]);
onlineUsers$ = this.onlineUsersSource.asObservable();
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
public scanLibrary: EventEmitter<ProgressEvent> = new EventEmitter<ProgressEvent>(); // TODO: Refactor this name to be generic
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
/**
* Any events that come from the backend
*/
public messages$ = this.messagesSource.asObservable();
/**
* Users that are online
*/
public onlineUsers$ = this.onlineUsersSource.asObservable();
isAdmin: boolean = false;
constructor(private toastr: ToastrService, private router: Router) {
}
/**
* Tests that an event is of the type passed
* @param event
* @param eventType
* @returns
*/
public isEventType(event: Message<any>, eventType: EVENTS) {
if (event.event == EVENTS.NotificationProgress) {
const notification = event.payload as NotificationProgressEvent;
return notification.eventType.toLowerCase() == eventType.toLowerCase();
}
return event.event === eventType;
}
createHubConnection(user: User, isAdmin: boolean) {
@ -83,7 +126,6 @@ export class MessageHubService {
event: EVENTS.ScanSeries,
payload: resp.body
});
this.scanSeries.emit(resp.body);
});
this.hubConnection.on(EVENTS.ScanLibraryProgress, resp => {
@ -91,34 +133,27 @@ export class MessageHubService {
event: EVENTS.ScanLibraryProgress,
payload: resp.body
});
this.scanLibrary.emit(resp.body);
});
this.hubConnection.on(EVENTS.BackupDatabaseProgress, resp => {
this.hubConnection.on(EVENTS.LibraryModified, resp => {
this.messagesSource.next({
event: EVENTS.BackupDatabaseProgress,
payload: resp.body
event: EVENTS.LibraryModified,
payload: resp.body as LibraryModifiedEvent
});
});
this.hubConnection.on(EVENTS.CleanupProgress, resp => {
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({
event: EVENTS.CleanupProgress,
payload: resp.body
event: EVENTS.NotificationProgress,
payload: resp
});
});
this.hubConnection.on(EVENTS.DownloadProgress, resp => {
this.hubConnection.on(EVENTS.SiteThemeProgress, resp => {
this.messagesSource.next({
event: EVENTS.DownloadProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
this.messagesSource.next({
event: EVENTS.RefreshMetadataProgress,
payload: resp.body
event: EVENTS.SiteThemeProgress,
payload: resp.body as SiteThemeProgressEvent
});
});
@ -129,14 +164,11 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.ScanLibraryError, resp => {
this.hubConnection.on(EVENTS.Error, resp => {
this.messagesSource.next({
event: EVENTS.ScanLibraryError,
event: EVENTS.Error,
payload: resp.body
});
if (this.isAdmin) {
this.toastr.error('Library Scan had a critical error. Some series were not saved. Check logs');
}
});
this.hubConnection.on(EVENTS.SeriesAdded, resp => {
@ -144,7 +176,6 @@ export class MessageHubService {
event: EVENTS.SeriesAdded,
payload: resp.body
});
this.seriesAdded.emit(resp.body);
});
this.hubConnection.on(EVENTS.SeriesRemoved, resp => {
@ -154,14 +185,6 @@ export class MessageHubService {
});
});
// this.hubConnection.on(EVENTS.RefreshMetadata, resp => {
// this.messagesSource.next({
// event: EVENTS.RefreshMetadata,
// payload: resp.body
// });
// this.refreshMetadata.emit(resp.body); // TODO: Remove this
// });
this.hubConnection.on(EVENTS.CoverUpdate, resp => {
this.messagesSource.next({
event: EVENTS.CoverUpdate,
@ -186,5 +209,5 @@ export class MessageHubService {
sendMessage(methodName: string, body?: any) {
return this.hubConnection.invoke(methodName, body);
}
}

View file

@ -1,15 +1,17 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { TypeaheadSettings } from '../typeahead/typeahead-settings';
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 { Person, PersonRole } from '../_models/person';
import { Tag } from '../_models/tag';
@Injectable({
@ -21,7 +23,7 @@ export class MetadataService {
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
constructor(private httpClient: HttpClient) { }
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
@ -77,6 +79,13 @@ export class MetadataService {
return this.httpClient.get<Array<Language>>(this.baseUrl + method);
}
/**
* All the potential language tags there can be
*/
getAllValidLanguages() {
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages');
}
getAllPeople(libraries?: Array<number>) {
let method = 'metadata/people'
if (libraries != undefined && libraries.length > 0) {

View file

@ -1,52 +1,77 @@
import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { ReplaySubject, take } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class NavService {
public localStorageSideNavKey = 'kavita--sidenav--expanded';
private navbarVisibleSource = new ReplaySubject<boolean>(1);
/**
* If the top Nav bar is rendered or not
*/
navbarVisible$ = this.navbarVisibleSource.asObservable();
private darkMode: boolean = true;
private darkModeSource = new ReplaySubject<boolean>(1);
darkMode$ = this.darkModeSource.asObservable();
private sideNavCollapseSource = new ReplaySubject<boolean>(1);
/**
* If the Side Nav is in a collapsed state or not.
*/
sideNavCollapsed$ = this.sideNavCollapseSource.asObservable();
private sideNavVisibilitySource = new ReplaySubject<boolean>(1);
/**
* If the side nav is rendered or not into the DOM.
*/
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
private renderer: Renderer2;
constructor(rendererFactory: RendererFactory2) {
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2) {
this.renderer = rendererFactory.createRenderer(null, null);
this.showNavBar();
const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false;
this.sideNavCollapseSource.next(sideNavState);
this.showSideNav();
}
/**
* Shows the top nav bar. This should be visible on all pages except the reader.
*/
showNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px');
this.navbarVisibleSource.next(true);
}
/**
* Hides the top nav bar.
*/
hideNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px');
this.navbarVisibleSource.next(false);
}
toggleDarkMode() {
this.darkMode = !this.darkMode;
this.updateColorScheme();
this.darkModeSource.next(this.darkMode);
/**
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
*/
showSideNav() {
this.sideNavVisibilitySource.next(true);
}
setDarkMode(mode: boolean) {
this.darkMode = mode;
this.updateColorScheme();
this.darkModeSource.next(this.darkMode);
/**
* Hides the side nav. This is useful for the readers and login page.
*/
hideSideNav() {
this.sideNavVisibilitySource.next(false);
}
private updateColorScheme() {
if (this.darkMode) {
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'dark');
} else {
this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'light');
}
toggleSideNav() {
this.sideNavCollapseSource.pipe(take(1)).subscribe(val => {
if (val === undefined) val = false;
const newVal = !(val || false);
this.sideNavCollapseSource.next(newVal);
localStorage.setItem(this.localStorageSideNavKey, newVal + '');
});
}
}

View file

@ -114,11 +114,11 @@ export class ReaderService {
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/
setOverrideStyles() {
setOverrideStyles(backgroundColor: string = 'black') {
const bodyNode = document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
this.originalBodyColor = bodyNode.style.background;
bodyNode.setAttribute('style', 'background-color: black !important');
bodyNode.setAttribute('style', 'background-color: ' + backgroundColor + ' !important');
}
}

View file

@ -30,6 +30,10 @@ export class ReadingListService {
);
}
getReadingListsForSeries(seriesId: number) {
return this.httpClient.get<ReadingList[]>(this.baseUrl + 'readinglist/lists-for-series?seriesId=' + seriesId);
}
getListItems(readingListId: number) {
return this.httpClient.get<ReadingListItem[]>(this.baseUrl + 'readinglist/items?readingListId=' + readingListId);
}
@ -43,11 +47,11 @@ export class ReadingListService {
}
updateByMultiple(readingListId: number, seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds});
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}, { responseType: 'text' as 'json' });
}
updateByMultipleSeries(readingListId: number, seriesIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds});
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, { responseType: 'text' as 'json' });
}
updateBySeries(readingListId: number, seriesId: number) {

View file

@ -4,10 +4,12 @@ import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Chapter } from '../_models/chapter';
import { ChapterMetadata } from '../_models/chapter-metadata';
import { CollectionTag } from '../_models/collection-tag';
import { PaginatedResult } from '../_models/pagination';
import { RecentlyAddedItem } from '../_models/recently-added-item';
import { Series } from '../_models/series';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesFilter } from '../_models/series-filter';
import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/series-metadata';
@ -84,6 +86,10 @@ export class SeriesService {
return this.httpClient.get<Chapter>(this.baseUrl + 'series/chapter?chapterId=' + chapterId);
}
getChapterMetadata(chapterId: number) {
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
}
getData(id: number) {
return of(id);
}
@ -159,10 +165,10 @@ export class SeriesService {
}));
}
updateMetadata(seriesMetadata: SeriesMetadata, tags: CollectionTag[]) {
updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) {
const data = {
seriesMetadata,
tags
collectionTags,
};
return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'});
}
@ -171,11 +177,6 @@ export class SeriesService {
let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
// NOTE: I'm not sure the paginated result is doing anything
// if (this.paginatedSeriesForTagsResults?.pagination !== undefined && this.paginatedSeriesForTagsResults?.pagination?.currentPage === pageNum) {
// return of(this.paginatedSeriesForTagsResults);
// }
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe(
map((response: any) => {
@ -184,6 +185,10 @@ export class SeriesService {
);
}
getSeriesDetail(seriesId: number) {
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
}
_addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) {
if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) {
params = params.append('pageNumber', pageNum + '');
@ -220,6 +225,7 @@ export class SeriesService {
tags: [],
languages: [],
publicationStatus: [],
seriesNameQuery: '',
};
if (filter === undefined) return data;

View file

@ -12,6 +12,10 @@ export class UploadService {
constructor(private httpClient: HttpClient) { }
uploadByUrl(url: string) {
return this.httpClient.post<string>(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'});
}
/**
*
* @param seriesId Series to overwrite cover image for

View file

@ -1,30 +1,25 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Choose a Directory</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="filter">Filter</label>
<div class="mb-3">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
</div>
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
{{route}}
</ng-container>
<ng-template #nonActive>
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
</ng-template>
</li>
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
@ -33,17 +28,17 @@
<ul class="list-group">
<div class="list-group-item list-group-item-action">
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
<i class="fa fa-arrow-left mr-2" aria-hidden="true"></i>
<i class="fa fa-arrow-left me-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>
<button type="button" class="btn btn-primary float-end" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
<ul class="list-group scrollable">
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
<span>{{getStem(folder)}}</span>
<button type="button" class="btn btn-primary float-right" (click)="shareFolder(folder, $event)">Share</button>
<button type="button" class="btn btn-primary float-end" (click)="shareFolder(folder, $event)">Share</button>
</button>
<div class="list-group-item text-center" *ngIf="folders.length === 0">
There are no folders here
@ -51,6 +46,6 @@
</ul>
</div>
<div class="modal-footer">
<a class="btn btn-info" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>
</div>

View file

@ -21,7 +21,7 @@ export class DirectoryPickerComponent implements OnInit {
/**
* Url to give more information about selecting directories. Passing nothing will suppress.
*/
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library';
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita';
currentRoot = '';
folders: string[] = [];

View file

@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Library Access</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">

View file

@ -2,35 +2,35 @@
<form [formGroup]="libraryForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{this.library !== undefined ? 'Edit' : 'New'}} Library</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="form-group">
<label for="library-name">Name</label>
<div class="mb-3">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="name" type="text">
</div>
<div class="form-group">
<label for="library-type">Type</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="typeTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="library-type" class="form-label">Type</label>&nbsp;<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">
<span class="visually-hidden" 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-select" 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>
<h4>Folders <button type="button" class="btn float-right btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
<h4>Folders <button type="button" class="btn float-end btn-sm" (click)="openDirectoryPicker()"><i class="fa fa-plus" aria-hidden="true"></i></button></h4>
<ul class="list-group" style="width: 100%">
<li class="list-group-item" *ngFor="let folder of selectedFolders; let i = index">
{{folder}}
<button class="btn float-right btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
</li>
</ul>
</div>

View file

@ -66,7 +66,8 @@ export class LibraryEditorModalComponent implements OnInit {
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
this.libraryService.create(model).subscribe(() => {
this.toastr.success('Library created, a scan has been started');
this.toastr.success('Library created successfully.');
this.toastr.info('A scan has been started.');
this.close(true);
}, err => {
this.errorMessage = err;

View file

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

View file

@ -13,12 +13,13 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
import { ManageSettingsComponent } from './manage-settings/manage-settings.component';
import { ManageSystemComponent } from './manage-system/manage-system.component';
import { ChangelogComponent } from './changelog/changelog.component';
import { PipeModule } from '../pipe/pipe.module';
import { InviteUserComponent } from './invite-user/invite-user.component';
import { RoleSelectorComponent } from './role-selector/role-selector.component';
import { LibrarySelectorComponent } from './library-selector/library-selector.component';
import { EditUserComponent } from './edit-user/edit-user.component';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { SidenavModule } from '../sidenav/sidenav.module';
@ -34,7 +35,6 @@ import { EditUserComponent } from './edit-user/edit-user.component';
ResetPasswordModalComponent,
ManageSettingsComponent,
ManageSystemComponent,
ChangelogComponent,
InviteUserComponent,
RoleSelectorComponent,
LibrarySelectorComponent,
@ -49,7 +49,9 @@ import { EditUserComponent } from './edit-user/edit-user.component';
NgbTooltipModule,
NgbDropdownModule,
SharedModule,
PipeModule
PipeModule,
SidenavModule,
UserSettingsModule // API-key componet
],
providers: []
})

View file

@ -1,21 +0,0 @@
<div class="changelog">
<ng-container *ngFor="let update of updates; let indx = index;">
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp;
<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>
</div>
</ng-container>
</div>
<div class="spinner-border text-secondary" *ngIf="isLoading" role="status">
<span class="invisible">Loading...</span>
</div>

View file

@ -1,7 +1,10 @@
<div class="container">
<h2>Admin Dashboard</h2>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<app-side-nav-companion-bar>
<h2 title>
Admin Dashboard
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
@ -17,9 +20,6 @@
<ng-container *ngIf="tab.fragment === 'system'">
<app-manage-system></app-manage-system>
</ng-container>
<ng-container *ngIf="tab.fragment === 'changelog'">
<app-changelog></app-changelog>
</ng-container>
</ng-template>
</li>
</ul>

View file

@ -0,0 +1,3 @@
.container {
padding-top: 10px;
}

View file

@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ServerService } from 'src/app/_services/server.service';
import { Title } from '@angular/platform-browser';
import { NavService } from '../../_services/nav.service';
@ -18,13 +19,12 @@ export class DashboardComponent implements OnInit {
{title: 'Users', fragment: 'users'},
{title: 'Libraries', fragment: 'libraries'},
{title: 'System', fragment: 'system'},
{title: 'Changelog', fragment: 'changelog'},
];
counter = this.tabs.length + 1;
active = this.tabs[0];
constructor(public route: ActivatedRoute, private serverService: ServerService,
private toastr: ToastrService, private titleService: Title) {
private toastr: ToastrService, private titleService: Title, public navService: NavService) {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {

View file

@ -1,16 +1,16 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="userForm">
<div class="row no-gutters">
<div class="col-md-6 col-sm-12 pr-2">
<div class="form-group">
<label for="username">Username</label>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
@ -20,8 +20,8 @@
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" [disabled]="true">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
@ -35,7 +35,7 @@
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>

View file

@ -1,7 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -9,16 +9,10 @@
Invite a user to your server. Enter their email in and we will send them an email to create an account.
</p>
<p *ngIf="!checkedAccessibility">
<span class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" aria-hidden="true"></span>
&nbsp;Checking accessibility of server...
</p>
<form [formGroup]="inviteForm">
<div class="row no-gutters">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
@ -28,12 +22,7 @@
</div>
</div>
<ng-container *ngIf="emailLink !== '' && checkedAccessibility && !accessible">
<p>Use this link to finish setting up the user account due to your server not being accessible outside your local network.</p>
<a class="email-link" href="{{emailLink}}" target="_blank">{{emailLink}}</a>
</ng-container>
<div class="row no-gutters">
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
@ -44,12 +33,21 @@
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || !checkedAccessibility || emailLink !== ''">
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>

View file

@ -3,6 +3,7 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { InviteUserResponse } from 'src/app/_models/invite-user-response';
import { Library } from 'src/app/_models/library';
import { AccountService } from 'src/app/_services/account.service';
import { ServerService } from 'src/app/_services/server.service';
@ -19,15 +20,12 @@ export class InviteUserComponent implements OnInit {
*/
isSending: boolean = false;
inviteForm: FormGroup = new FormGroup({});
/**
* If a user would be able to load this server up externally
*/
accessible: boolean = true;
checkedAccessibility: boolean = false;
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
emailLink: string = '';
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
public get email() { return this.inviteForm.get('email'); }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
@ -35,14 +33,6 @@ export class InviteUserComponent implements OnInit {
ngOnInit(): void {
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
this.serverService.isServerAccessible().subscribe(async (accessibile) => {
if (!accessibile) {
await this.confirmService.alert('This server is not accessible outside the network. You cannot invite via Email. You wil be given a link to finish registration with instead.');
this.accessible = accessibile;
}
this.checkedAccessibility = true;
});
}
close() {
@ -57,11 +47,10 @@ export class InviteUserComponent implements OnInit {
email,
libraries: this.selectedLibraries,
roles: this.selectedRoles,
sendEmail: this.accessible
}).subscribe(emailLink => {
this.emailLink = emailLink;
}).subscribe((data: InviteUserResponse) => {
this.emailLink = data.emailLink;
this.isSending = false;
if (this.accessible) {
if (data.emailSent) {
this.toastr.info('Email sent to ' + email);
this.modal.close(true);
}

View file

@ -1,19 +1,19 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Libraries</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="addLibrary()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add Library</span></button></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="addLibrary()" title="Add Library"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add Library</span></button></div>
</div>
<ul class="list-group" *ngIf="!createLibraryToggle; else createLibrary">
<li *ngFor="let library of libraries; let idx = index; trackby: trackbyLibrary" class="list-group-item">
<li *ngFor="let library of libraries; let idx = index; trackby: trackbyLibrary" class="list-group-item no-hover">
<div>
<h4>
<span id="library-name--{{idx}}">{{library.name}}</span>&nbsp;
<div class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" *ngIf="scanInProgress.hasOwnProperty(library.id) && scanInProgress[library.id].progress" title="Scan in progress. Started at {{scanInProgress[library.id].timestamp | date: 'short'}}">
<span class="sr-only">Scan for {{library.name}} in progress</span>
</div>
<div class="float-right">
<button class="btn btn-secondary mr-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
<button class="btn btn-danger mr-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | sentenceCase}}"></i></button>
<span id="library-name--{{idx}}"><a [routerLink]="'/library/' + library.id">{{library.name}}</a></span>&nbsp;
<!-- <div class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" *ngIf="scanInProgress.hasOwnProperty(library.id) && scanInProgress[library.id].progress" title="Scan in progress. Started at {{scanInProgress[library.id].timestamp | date: 'short'}}">
<span class="visually-hidden">Scan for {{library.name}} in progress</span>
</div> -->
<div class="float-end">
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" attr.aria-label="Scan Library"><i class="fa fa-sync-alt" title="Scan"></i></button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" ngbTooltip="Delete Library" attr.aria-label="Delete {{library.name | sentenceCase}}"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{library.name | sentenceCase}}"></i></button>
</div>
</h4>

View file

@ -2,12 +2,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { distinctUntilChanged, filter, take, takeLast, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { ProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event';
import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event';
import { Library, LibraryType } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service';
import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component';
@Component({
@ -24,7 +25,6 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
* If a deletion is in progress for a library
*/
deletionInProgress: boolean = false;
scanInProgress: {[key: number]: {progress: boolean, timestamp?: string}} = {};
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
private readonly onDestroy = new Subject<void>();
@ -37,27 +37,29 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
this.getLibraries();
// when a progress event comes in, show it on the UI next to library
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
if (event.event !== EVENTS.ScanLibraryProgress) return;
this.hubService.messages$.pipe(takeUntil(this.onDestroy),
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
this.hasMessageChanged(prev, curr)))
.subscribe((event: Message<ScanSeriesEvent | NotificationProgressEvent>) => {
console.log('scan event: ', event);
let libId = 0;
if (event.event === EVENTS.ScanSeries) {
libId = (event.payload as ScanSeriesEvent).libraryId;
} else {
if ((event.payload as NotificationProgressEvent).body.hasOwnProperty('libraryId')) {
libId = (event.payload as NotificationProgressEvent).body.libraryId;
}
}
console.log('scan event: ', event.payload);
const scanEvent = event.payload as ProgressEvent;
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 === 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);
const newLibrary = libraries.find(lib => lib.id === libId);
const existingLibrary = this.libraries.find(lib => lib.id === libId);
if (existingLibrary !== undefined) {
existingLibrary.lastScanned = newLibrary?.lastScanned || existingLibrary.lastScanned;
}
});
}
});
}
@ -66,6 +68,17 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
this.onDestroy.complete();
}
hasMessageChanged(prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) {
if (curr.event !== prev.event) return true;
if (curr.event === EVENTS.ScanSeries) {
return (prev.payload as ScanSeriesEvent).libraryId === (curr.payload as ScanSeriesEvent).libraryId;
}
if (curr.event === EVENTS.NotificationProgress) {
return (prev.payload as NotificationProgressEvent).eventType != (curr.payload as NotificationProgressEvent).eventType;
}
return false;
}
getLibraries() {
this.loading = true;
this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => {
@ -106,7 +119,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
scanLibrary(library: Library) {
this.libraryService.scan(library.id).pipe(take(1)).subscribe(() => {
this.toastr.success('A scan has been queued for ' + library.name);
this.toastr.info('A scan has been queued for ' + library.name);
});
}

View file

@ -1,65 +1,56 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<p class="text-warning pt-2">Port and Logging Level require a manual restart of Kavita to take effect.</p>
<div class="form-group">
<label for="settings-cachedir">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-cachedir" class="form-label">Cache Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #cacheDirectoryTooltip>Where the server place temporary files when reading. This will be cleaned up on a regular basis.</ng-template>
<span class="sr-only" id="settings-cachedir-help">Where the server place temporary files when reading. This will be cleaned up on a regular basis.</span>
<span class="visually-hidden" id="settings-cachedir-help">Where the server place temporary files when reading. This will be cleaned up on a regular basis.</span>
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
</div>
<div class="form-group">
<label for="settings-bookmarksdir">Bookmarks Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-bookmarksdir" class="form-label">Bookmarks Directory</label>&nbsp;<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>
<span class="visually-hidden" 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>
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
Change
</button>
</div>
</div>
<!-- <div class="form-group">
<label for="settings-baseurl">Base Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
<span class="sr-only" id="settings-baseurl-help">Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</span>
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
</div> -->
<div class="row no-gutters">
<div class="form-group col-md-6 col-sm-12 pr-2">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<div class="row g-0 mb-2">
<div class="col-md-6 col-sm-12 pe-2">
<label for="settings-port" class="form-label">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<span class="visually-hidden" 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>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<div class="col-md-6 col-sm-12">
<label for="logging-level-port" class="form-label">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<span class="visually-hidden" 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-select" 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">
<label for="stat-collection" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect.</p>
<div class="form-check">
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<div class="form-group">
<label for="opds" aria-describedby="opds-info">OPDS</label>
<div class="mb-3">
<label for="opds" aria-describedby="opds-info" class="form-label">OPDS</label>
<p class="accent" id="opds-info">OPDS support will allow all users to use OPDS to read and download content from the server. If OPDS is enabled, a user will not need download permissions to download media while using it.</p>
<div class="form-check">
<div class="form-check form-switch">
<input id="opds" type="checkbox" aria-label="OPDS Support" class="form-check-input" formControlName="enableOpds">
<label for="opds" class="form-check-label">Enable OPDS</label>
</div>
@ -67,50 +58,48 @@
<h4>Email Services (SMTP)</h4>
<p class="accent">Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails although confirmation links will always
be saved to logs.
</p>
<div class="form-group">
<label for="settings-emailservice">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>Use fully qualified url of the email service. Do not include ending slash.</ng-template>
<span class="sr-only" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<div class="input-group">
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button class="btn btn-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-tasks-scan" class="form-label">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metatdata around manga files.</ng-template>
<span class="sr-only" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
<select class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<span class="visually-hidden" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-tasks-backup">Library Database Backup</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<div class="mb-3">
<label for="settings-tasks-backup" class="form-label">Library Database Backup</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How often Kavita will backup the database.</ng-template>
<span class="sr-only" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
<select class="form-control" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<span class="visually-hidden" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
</select>
</div>
<div class="float-right">
<button type="button" class="btn btn-secondary mr-2" (click)="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 class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</div>

View file

@ -1,11 +1,11 @@
<div class="container-fluid">
<div class="float-right">
<div class="float-end">
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown">
<button class="btn btn-outline-primary mr-2" id="dropdownManual" ngbDropdownToggle>
<button class="btn btn-outline-primary me-2" id="dropdownManual" ngbDropdownToggle>
<ng-container *ngIf="backupDBInProgress || clearCacheInProgress || isCheckingForUpdate || downloadLogsInProgress">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</ng-container>
Actions
</button>
@ -28,7 +28,7 @@
<h3>About System</h3>
<hr/>
<div class="form-group" *ngIf="serverInfo">
<div class="mb-3" *ngIf="serverInfo">
<dl>
<dt>Version</dt>
<dd>{{serverInfo.kavitaVersion}}</dd>

View file

@ -4,28 +4,28 @@
<ng-container>
<div class="row mb-2">
<div class="col-8"><h3>Pending Invites</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
</div>
<ul class="list-group">
<li class="list-group-item" *ngFor="let invite of pendingInvites; let idx = index;">
<li class="list-group-item no-hover" *ngFor="let invite of pendingInvites; let idx = index;">
<div>
<h4>
<span id="member-name--{{idx}}">{{invite.username | titlecase}} </span>
<div class="float-right">
<button class="btn btn-danger mr-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary mr-2" (click)="resendEmail(invite)">Resend</button>
<div class="float-end">
<button class="btn btn-danger me-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary me-2" (click)="resendEmail(invite)">Resend</button>
</div>
</h4>
<div>Invited: {{invite.created | date: 'short'}}</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item">
<li *ngIf="loadingMembers" class="list-group-item no-hover">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item" *ngIf="pendingInvites.length === 0 && !loadingMembers">
<li class="list-group-item no-hover" *ngIf="pendingInvites.length === 0 && !loadingMembers">
There are no invited Users
</li>
</ul>
@ -35,19 +35,19 @@
<h3 class="mt-3">Active Users</h3>
<ul class="list-group">
<li *ngFor="let member of members; let idx = index;" class="list-group-item">
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
<div>
<h4>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
<span id="member-name--{{idx}}">{{member.username | titlecase}} </span>
<span *ngIf="member.username === loggedInUsername">
<i class="fas fa-star" aria-hidden="true"></i>
<span class="sr-only">(You)</span>
<span class="visually-hidden">(You)</span>
</span>
<div class="float-right" *ngIf="canEditMember(member)">
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
<div class="float-end" *ngIf="canEditMember(member)">
<button class="btn btn-danger btn-sm me-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary btn-sm me-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary btn-sm" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
</div>
</h4>
<div>Last Active:
@ -57,10 +57,10 @@
</ng-template>
</div>
<div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div>
<div>
<div class="row g-0">
Roles: <span *ngIf="getRoles(member).length === 0; else showRoles">None</span>
<ng-template #showRoles>
<app-tag-badge *ngFor="let role of getRoles(member)">{{role}}</app-tag-badge>
<app-tag-badge *ngFor="let role of getRoles(member)" class="col-auto">{{role}}</app-tag-badge>
</ng-template>
</div>
</div>

View file

@ -35,8 +35,10 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
private confirmService: ConfirmService,
public messageHub: MessageHubService,
private serverService: ServerService) {
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
this.loggedInUsername = user.username;
this.accountService.currentUser$.pipe(take(1)).subscribe((user) => {
if (user) {
this.loggedInUsername = user.username;
}
});
}
@ -72,6 +74,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
}
loadPendingInvites() {
this.pendingInvites = [];
this.memberService.getPendingInvites().subscribe(members => {
this.pendingInvites = members;
// Show logged in user at the top of the list
@ -114,9 +117,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
inviteUser() {
const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'});
modalRef.closed.subscribe((successful: boolean) => {
if (successful) {
this.loadPendingInvites();
}
this.loadPendingInvites();
});
}

View file

@ -1,10 +1,17 @@
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)">
<h2 title>
<app-card-actionables [actions]="actions"></app-card-actionables>
All Series
</h2>
<h6 subtitle>{{pagination?.totalItems}} Series</h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout header="All Series"
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[actions]="actions"
[pagination]="pagination"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
>

View file

@ -1,19 +1,18 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, 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 { FilterSettings } from '../metadata-filter/filter-settings';
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 { FilterEvent, 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 { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service';
@Component({
@ -30,6 +29,7 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
filter: SeriesFilter | undefined = undefined;
onDestroy: Subject<void> = new Subject<void>();
filterSettings: FilterSettings = new FilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
bulkActionCallback = (action: Action, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -82,7 +82,8 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => {
this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: Message<any>) => {
if (event.event !== EVENTS.SeriesAdded) return;
this.loadPage();
});
}

View file

@ -0,0 +1,23 @@
import { NgModule } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";
import { AdminGuard } from "../_guards/admin.guard";
import { AuthGuard } from "../_guards/auth.guard";
import { AnnouncementsComponent } from "./announcements.component";
const routes: Routes = [
{path: '**', component: AnnouncementsComponent, pathMatch: 'full', canActivate: [AuthGuard, AdminGuard]},
{
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard, AdminGuard],
children: [
{path: '/announcments', component: AnnouncementsComponent},
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AnnouncementsRoutingModule { }

View file

@ -0,0 +1,7 @@
<app-side-nav-companion-bar>
<h2 title>
Announcements
</h2>
</app-side-nav-companion-bar>
<app-changelog></app-changelog>

View file

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-announcements',
templateUrl: './announcements.component.html',
styleUrls: ['./announcements.component.scss']
})
export class AnnouncementsComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View file

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AnnouncementsComponent } from './announcements.component';
import { ChangelogComponent } from './changelog/changelog.component';
import { AnnouncementsRoutingModule } from './announcements-routing.module';
import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { SidenavModule } from '../sidenav/sidenav.module';
@NgModule({
declarations: [
AnnouncementsComponent,
ChangelogComponent
],
imports: [
CommonModule,
AnnouncementsRoutingModule,
SharedModule,
PipeModule,
SidenavModule
]
})
export class AnnouncementsModule { }

View file

@ -0,0 +1,25 @@
<div class="changelog">
<ng-container *ngFor="let update of updates; let indx = index;">
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp;
<span class="badge bg-secondary" *ngIf="update.updateVersion === installedVersion">Installed</span>
<span class="badge bg-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">
<app-read-more [text]="update.updateBody" [maxLength]="500"></app-read-more>
</pre>
<a *ngIf="!update.isDocker && update.updateVersion === installedVersion" href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank">Installed</a>
<a *ngIf="!update.isDocker && update.updateVersion !== installedVersion" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank">Download</a>
</div>
</div>
</ng-container>
</div>
<div class="spinner-border text-secondary" *ngIf="isLoading" role="status">
<span class="invisible">Loading...</span>
</div>

View file

@ -1,16 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { UserLoginComponent } from './user-login/user-login.component';
import { 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';
import { ThemeTestComponent } from './theme-test/theme-test.component';
import { ReadingListsComponent } from './reading-list/reading-lists/reading-lists.component';
import { AllCollectionsComponent } from './collections/all-collections/all-collections.component';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
@ -36,6 +37,14 @@ const routes: Routes = [
canActivate: [AuthGuard],
loadChildren: () => import('./reading-list/reading-list.module').then(m => m.ReadingListModule)
},
{
path: 'registration',
loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)
},
{
path: 'announcements',
loadChildren: () => import('../app/announcements/announcements.module').then(m => m.AnnouncementsModule)
},
{
path: '',
runGuardsAndResolvers: 'always',
@ -60,17 +69,13 @@ const routes: Routes = [
children: [
{path: 'library', component: DashboardComponent},
{path: 'recently-added', component: RecentlyAddedComponent},
{path: 'on-deck', component: OnDeckComponent},
{path: 'all-series', component: AllSeriesComponent},
]
},
{
path: 'registration',
loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)
},
{path: 'theme', component: ThemeTestComponent},
{path: 'login', component: UserLoginComponent}, // TODO: move this to registration module
{path: 'no-connection', component: NotConnectedComponent},
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
];

View file

@ -1,5 +1,16 @@
<app-nav-header></app-nav-header>
<div [ngStyle]="(navService?.navbarVisible$ | async) ? {'padding-top': 'calc(56px + 5px)', 'height': '100%'} : {}">
<div [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
<a id="content"></a>
<router-outlet></router-outlet>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
<div class="container-fluid">
<div style="padding-top: 10px; padding-bottom: 65px;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService?.sideNavCollapsed$ | async)}">
<router-outlet></router-outlet>
</div>
</div>
<ng-template #noSideNav>
<router-outlet></router-outlet>
</ng-template>
</div>
</div>

View file

@ -0,0 +1,39 @@
.content-wrapper {
padding: 0 10px 0;
height: 100%;
}
.companion-bar {
transition: all var(--side-nav-companion-bar-transistion);
margin-left: 40px;
}
.companion-bar-content {
margin-left: 190px;
width: auto;
}
@media (max-width: 576px) {
.container-fluid {
padding: 0;
}
.content-wrapper {
padding: 0 10px 0;
overflow: hidden;
&.closed {
overflow: auto;
}
}
.companion-bar {
margin-left: 0px;
padding-left: 0px;
}
.companion-bar-content {
margin-left: 0px;
width: auto;
}
}

View file

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, HostListener, Inject, OnInit } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { AccountService } from './_services/account.service';
@ -7,6 +7,7 @@ import { MessageHubService } from './_services/message-hub.service';
import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT } from '@angular/common';
@Component({
selector: 'app-root',
@ -17,7 +18,8 @@ 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 ratingConfig: NgbRatingConfig) {
router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
@Inject(DOCUMENT) private document: Document) {
// Setup default rating config
ratingConfig.max = 5;
@ -33,22 +35,33 @@ export class AppComponent implements OnInit {
});
}
ngOnInit(): void {
this.setCurrentUser();
@HostListener('resize')
onResize() {
this.setDocHeight();
}
@HostListener('orientationchange')
onOrientationChange() {
this.setDocHeight();
}
ngOnInit(): void {
this.setCurrentUser();
this.setDocHeight();
}
setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage();
this.accountService.setCurrentUser(user);
if (user) {
this.navService.setDarkMode(user.preferences.siteDarkMode);
this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user));
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
} else {
this.navService.setDarkMode(true);
}
}
}
setDocHeight() {
// Sets a CSS variable for the actual device viewport height. Needed for mobile dev.
this.document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`);
}
}

View file

@ -7,7 +7,8 @@ import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import {
NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -17,26 +18,25 @@ import { LibraryComponent } from './library/library.component';
import { SharedModule } from './shared/shared.module';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module';
import { TypeaheadModule } from './typeahead/typeahead.module';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { OnDeckComponent } from './on-deck/on-deck.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module';
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 { EventsWidgetComponent } from './events-widget/events-widget.component';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { AllSeriesComponent } from './all-series/all-series.component';
import { PublicationStatusPipe } from './publication-status.pipe';
import { RegistrationModule } from './registration/registration.module';
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
import { ThemeTestComponent } from './theme-test/theme-test.component';
import { PipeModule } from './pipe/pipe.module';
import { ColorPickerModule } from 'ngx-color-picker';
import { SidenavModule } from './sidenav/sidenav.module';
@NgModule({
@ -44,20 +44,17 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
AppComponent,
NavHeaderComponent,
UserLoginComponent,
LibraryComponent,
LibraryDetailComponent,
SeriesDetailComponent,
NotConnectedComponent, // Move into ExtrasModule
LibraryComponent,
LibraryDetailComponent,
SeriesDetailComponent,
ReviewSeriesModalComponent,
RecentlyAddedComponent,
OnDeckComponent,
DashboardComponent,
NavEventsToggleComponent,
PersonRolePipe,
PublicationStatusPipe,
EventsWidgetComponent,
SeriesMetadataDetailComponent,
AllSeriesComponent,
GroupedTypeaheadComponent,
ThemeTestComponent,
],
imports: [
HttpClientModule,
@ -69,11 +66,11 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
NgbDropdownModule, // Nav
NgbPopoverModule, // Nav Events toggle
NgbRatingModule, // Series Detail
NgbRatingModule, // Series Detail & Filter
NgbNavModule,
NgbPaginationModule,
NgbCollapseModule, // Login
NgbCollapseModule, // Login
SharedModule,
CarouselModule,
@ -83,6 +80,15 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
ReadingListModule,
RegistrationModule,
ColorPickerModule, // User preferences
NgbAccordionModule, // ThemeTest Component only
PipeModule,
PipeModule,
SidenavModule, // For sidenav
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',
preventDuplicates: true,
@ -96,7 +102,6 @@ import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
Title,
{provide: SAVER, useFactory: getSaver},
{ provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] },
],
entryComponents: [],
bootstrap: [AppComponent]

View file

@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module';
import { SafeStylePipe } from './safe-style.pipe';
import { ReactiveFormsModule } from '@angular/forms';
import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { PipeModule } from '../pipe/pipe.module';
@NgModule({
@ -16,7 +17,8 @@ import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstr
ReactiveFormsModule,
SharedModule,
NgbProgressbarModule,
NgbTooltipModule
NgbTooltipModule,
PipeModule,
], exports: [
BookReaderComponent,
SafeStylePipe

View file

@ -1,23 +1,20 @@
<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>
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="drawerBackgroundColor" (drawerClosed)="closeDrawer()">
<div header>
<h2 style="margin-top: 0.5rem">Book Settings
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
<span aria-hidden="true">&times;</span>
</button>
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()"></button>
</h2>
</div>
<div body class="drawer-body">
<div class="control-container">
<div class="controls">
<form [formGroup]="settingsForm">
<div class="form-group">
<label for="library-type">Font Family</label>
<div class="mb-3">
<label for="library-type" class="form-label">Font Family</label>
<select class="form-control" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontFamilies; let i = index">{{opt | titlecase}}</option>
</select>
@ -25,42 +22,42 @@
</form>
</div>
<div class="controls">
<label id="fontsize">Font Size</label>
<label id="fontsize" class="form-label">Font Size</label>
<button (click)="updateFontSize(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="fontsize"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['font-size']}}</span>
<button (click)="updateFontSize(10)" class="btn btn-icon" title="Increase" aria-labelledby="fontsize"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="linespacing">Line Spacing</label>
<label id="linespacing" class="form-label">Line Spacing</label>
<button (click)="updateLineSpacing(-10)" class="btn btn-icon" title="Decrease" aria-labelledby="linespacing"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['line-height']}}</span>
<button (click)="updateLineSpacing(10)" class="btn btn-icon" title="Increase" aria-labelledby="linespacing"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="margin">Margin</label>
<label id="margin" class="form-label">Margin</label>
<button (click)="updateMargin(-5)" class="btn btn-icon" title="Remove Margin" aria-labelledby="margin"><i class="fa fa-minus" aria-hidden="true"></i></button>
<span>{{pageStyles['margin-right']}}</span>
<button (click)="updateMargin(5)" class="btn btn-icon" title="Add Margin" aria-labelledby="margin"><i class="fa fa-plus" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="readingdirection">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}"><i class="fa {{readingDirection === 0 ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}</span></button>
<label id="readingdirection" class="form-label">Reading Direction</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}"><i class="fa {{readingDirection === 0 ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i><span class="d-none d-sm-block">&nbsp;{{readingDirection === 0 ? 'Left to Right' : 'Right to Left'}}</span></button>
</div>
<div class="controls">
<label id="darkmode">Dark Mode</label>
<label id="darkmode" class="form-label">Dark Mode</label>
<button (click)="toggleDarkMode(false)" class="btn btn-icon" aria-labelledby="darkmode" title="Off"><i class="fa fa-sun" aria-hidden="true"></i></button>
<button (click)="toggleDarkMode(true)" class="btn btn-icon" aria-labelledby="darkmode" title="On"><i class="fa fa-moon" aria-hidden="true"></i></button>
</div>
<div class="controls">
<label id="tap-pagination">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tap-pagination-help"></i></label>
<label id="tap-pagination" class="form-label">Tap Pagination&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tap-pagination-help"></i></label>
<ng-template #tapPaginationTooltip>The ability to click the sides of the page to page left and right</ng-template>
<span class="sr-only" id="tap-pagination-help">The ability to click the sides of the page to page left and right</span>
<button (click)="toggleClickToPaginate()" class="btn btn-icon" aria-labelledby="tap-pagination"><i class="fa fa-arrows-alt-h {{clickToPaginate ? 'icon-primary-color' : ''}}" aria-hidden="true"></i><span *ngIf="darkMode">&nbsp;{{clickToPaginate ? 'On' : 'Off'}}</span></button>
<span class="visually-hidden" 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>&nbsp;{{clickToPaginate ? 'On' : 'Off'}}</button>
</div>
<div class="controls">
<label id="fullscreen">Fullscreen&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="fullscreenTooltip" role="button" tabindex="0" aria-describedby="fullscreen-help"></i></label>
<label id="fullscreen" class="form-label">Fullscreen&nbsp;<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">
<span class="visually-hidden" id="fullscreen-help">
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
</span>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
@ -68,18 +65,18 @@
<span *ngIf="darkMode">&nbsp;{{isFullscreen ? 'Exit' : 'Enter'}}</span>
</button>
</div>
<div class="row no-gutters justify-content-between">
<div class="row g-0 justify-content-between">
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
</div>
</div>
<div class="row no-gutters">
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<div class="col-1 page-stub">{{pageNum}}</div>
<div class="col-8" style="margin-top: 15px;padding-right:10px">
<div class="row g-0">
<button class="btn btn-small btn-icon col-1" style="padding-left: 0px" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<div class="col-1 page-stub ps-1">{{pageNum}}</div>
<div class="col-8 pe-1" style="margin-top: 15px">
<ngb-progressbar style="cursor: pointer" title="Go to page" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
</div>
<div class="col-1 btn-icon page-stub" (click)="goToPage(maxPages - 1)" title="Go to last page">{{maxPages - 1}}</div>
<button class="btn btn-small btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter()" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
<div class="col-1 btn-icon page-stub pe-1" (click)="goToPage(maxPages - 1)" title="Go to last page">{{maxPages - 1}}</div>
<button class="btn btn-small btn-icon col-1" style="padding-right: 0px; padding-left: 0px" [disabled]="nextChapterDisabled" (click)="loadNextChapter()" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
</div>
<div class="table-of-contents">
<h3>Table of Contents</h3>
@ -125,32 +122,32 @@
</div>
<ng-template #actionBar>
<div class="reading-bar row no-gutters justify-content-between">
<div class="reading-bar row g-0 justify-content-between">
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()"
[disabled]="IsPrevDisabled"
title="{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} Page">
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}}" aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}}</span>
<span class="d-none d-sm-block">&nbsp;{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}}</span>
</button>
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Go Back</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Settings</span></button>
<div class="book-title col-2 phone-hidden">
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="d-none d-sm-block">&nbsp;Go Back</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="d-none d-sm-block">Settings</span></button>
<div class="book-title col-2 d-none d-sm-block">
<ng-container *ngIf="isLoading; else showTitle">
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
<span class="sr-only">Loading book...</span>
<span class="visually-hidden">Loading book...</span>
</div>
</ng-container>
<ng-template #showTitle>
{{bookTitle}}
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode</span>)</span>
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" aria-label="Incognito mode is on. Toggle to turn off.">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">Incognito Mode</span>)</span>
</ng-template>
</div>
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Close</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="d-none d-sm-block">&nbsp;Close</span></button>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
[disabled]="IsNextDisabled"
(click)="nextPage()" title="{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} Page">
<span class="phone-hidden">{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}}&nbsp;</span>
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}}" aria-hidden="true"></i>
<span class="d-none d-sm-block">{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}}&nbsp;</span>
</button>
</div>
</ng-template>

View file

@ -24,6 +24,7 @@ import { ScrollService } from 'src/app/scroll.service';
import { MangaFormat } from 'src/app/_models/manga-format';
import { LibraryService } from 'src/app/_services/library.service';
import { LibraryType } from 'src/app/_models/library';
import { ThemeService } from 'src/app/theme.service';
interface PageStyle {
@ -260,8 +261,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
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,
@Inject(DOCUMENT) private document: Document) {
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {
this.navService.hideNavBar();
this.navService.hideSideNav();
this.darkModeStyleElem = this.renderer.createElement('style');
this.darkModeStyleElem.innerHTML = this.darkModeStyles;
@ -382,11 +384,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const bodyNode = this.document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) {
bodyNode.style.background = this.originalBodyColor;
if (this.user.preferences.siteDarkMode) {
bodyNode.classList.add('bg-dark');
}
this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => {
this.themeService.setTheme(theme.name);
});
}
this.navService.showNavBar();
this.navService.showSideNav();
const head = this.document.querySelector('head');
this.renderer.removeChild(head, this.darkModeStyleElem);
@ -737,7 +740,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => {
this.addLinkClickHandlers();
this.updateReaderStyles();
this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight;
// We need to get the offset after we ensure the title has rendered
requestAnimationFrame(() => this.topOffset = this.stickyTopElemRef.nativeElement?.getBoundingClientRect().height);
const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img');
if (imgs === null || imgs.length === 0) {
@ -779,13 +783,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
setPageNum(pageNum: number) {
if (pageNum < 0) {
this.pageNum = 0;
} else if (pageNum >= this.maxPages - 1) { // This case handles when we are using the pager to move to the next volume/chapter, the pageNum will get incremented past maxPages // NOTE: I made a change where I removed - 1 in comparison, it's breaking page progress
this.pageNum = this.maxPages; //
} else {
this.pageNum = pageNum;
}
this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0);
}
goBack() {
@ -968,7 +966,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
setOverrideStyles() {
const bodyNode = this.document.querySelector('body');
if (bodyNode !== undefined && bodyNode !== null) {
if (this.user.preferences.siteDarkMode) {
if (this.themeService.isDarkTheme()) {
bodyNode.classList.remove('bg-dark');
}
@ -1015,8 +1013,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (element === null) return;
this.scrollService.scrollTo(element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET, this.reader.nativeElement);
const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET;
// We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point
setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10);
}
toggleClickToPaginate() {

View file

@ -1,7 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}} Bookmarks</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -10,7 +10,7 @@
</p>
<ng-template #noBookmarks>No bookmarks yet</ng-template>
<div class="row no-gutters">
<div class="row g-0">
<div *ngFor="let bookmark of bookmarks; let idx = index">
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
</div>

View file

@ -7,7 +7,6 @@ import { PageBookmark } from 'src/app/_models/page-bookmark';
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';
@Component({
selector: 'app-bookmarks-modal',
@ -28,7 +27,7 @@ export class BookmarksModalComponent implements OnInit {
constructor(public imageService: ImageService, private readerService: ReaderService,
public modal: NgbActiveModal, private downloadService: DownloadService,
private toastr: ToastrService, private seriesService: SeriesService) { }
private toastr: ToastrService) { }
ngOnInit(): void {
this.init();

View file

@ -1,19 +1,17 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
<div class="form-group" *ngIf="lists.length >= 5">
<label for="filter">Filter</label>
<div class="mb-3" *ngIf="lists.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<ul class="list-group">
@ -23,16 +21,16 @@
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only">Loading...</span>
<span class="visually-hidden">Loading...</span>
</div>
</li>
</ul>
</div>
<div class="modal-footer" style="justify-content: normal">
<div style="width: 100%;">
<div class="form-row">
<div class="d-flex">
<div class="col-9 col-lg-10">
<label class="sr-only" for="add-rlist">Collection</label>
<label class="visually-hidden" class="form-label" for="add-rlist">Collection</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div>
<div class="col-2">

View file

@ -5,80 +5,154 @@
<ng-template #comicHeader><h4 class="modal-title" id="modal-basic-title">
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}} Details</h4>
</ng-template>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<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)">
<div class="row no-gutters">
<div class="col">
Id: {{data.id}}
</div>
<div class="col" *ngIf="series !== undefined">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
</div>
</div>
<div class="row no-gutters">
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
<div class="col">
Pages: {{data.pages}}
</div>
</div>
</ng-container>
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
<ul class="list-unstyled">
<li class="media my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
<app-image class="mr-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="media-body">
<h5 class="mt-0 mb-1">
<span *ngIf="chapter.number !== '0'; else specialHeader">
<span >
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>&nbsp;
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</span>
<span class="badge badge-primary badge-pill">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
</span>
</span>
<ng-template #specialHeader>File(s)</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="col">
Pages: {{file.pages}}
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]" *ngIf="!tabs[0].disabled">
<a ngbNavLink>{{tabs[0].title}}</a>
<ng-template ngbNavContent>
<div class="container-fluid row g-0">
<div class="col-md-2 col-xs-4 col-sm-6">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
ID: {{data.id}}
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row g-0">
<h4>
{{chapter?.titleName}}
</h4>
<span>
<span *ngIf="chapterMetadata && chapterMetadata.releaseDate !== null">Release Date: {{chapterMetadata.releaseDate | date: 'shortDate' || '-'}}</span>
</span>
<span class="text-accent">{{data.pages}} pages</span>
</div>
<div class="row g-0">
<div class="col-auto">
Added: {{(chapter.created | date: 'short') || '-'}}
</div>
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
<div class="row g-0">
<div class="col-auto">
Age Rating: {{ageRating}}
</div>
</div>
</div>
</div>
<div class="row g-0">
<ng-container *ngIf="chapterMetadata !== undefined">
<div class="row g-0" *ngIf="chapterMetadata.tags && chapterMetadata.tags.length > 0">
<h6>Tags</h6>
<app-badge-expander [items]="chapterMetadata.tags">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="row g-0" *ngIf="chapterMetadata.genres && chapterMetadata.genres.length > 0">
<h6>Genres</h6>
<app-badge-expander [items]="chapterMetadata.genres">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</div>
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]" *ngIf="!tabs[1].disabled">
<a ngbNavLink>{{tabs[1].title}}</a>
<ng-template ngbNavContent>
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]" *ngIf="!tabs[2].disabled">
<a ngbNavLink>{{tabs[2].title}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
<div class="row g-0">
<button class="btn btn-primary flex-end mb-2" [disabled]="coverImageSaveLoading" (click)="saveCoverImage()">
<ng-container *ngIf="coverImageSaveLoading; else notSaving">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notSaving>
Save
</ng-template>
</button>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[3]" *ngIf="!tabs[3].disabled">
<a ngbNavLink>{{tabs[3].title}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<ng-container *ngFor="let bookmark of bookmarks; let idx = index">
<app-bookmark [bookmark]="bookmark" class="col-auto" (bookmarkRemoved)="removeBookmark(bookmark, idx)"></app-bookmark>
</ng-container>
<ng-container *ngIf="bookmarks.length === 0">
No bookmarks yet
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[4]" *ngIf="!tabs[4].disabled">
<a ngbNavLink>{{tabs[4].title}}</a>
<ng-template ngbNavContent>
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span >
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<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">
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
</div>
</li>
</ul>
</div>
</li>
</ul>
</div>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-info" [disabled]="!isAdmin" (click)="updateCover()">Update Cover</button>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View file

@ -3,7 +3,7 @@ import { Router } from '@angular/router';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
@ -12,11 +12,16 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { UploadService } from 'src/app/_services/upload.service';
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
import { LibraryType } from '../../../_models/library';
import { LibraryService } from '../../../_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { Series } from 'src/app/_models/series';
import { PersonRole } from 'src/app/_models/person';
import { Volume } from 'src/app/_models/volume';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { ReaderService } from 'src/app/_services/reader.service';
import { MetadataService } from 'src/app/_services/metadata.service';
@ -30,38 +35,95 @@ export class CardDetailsModalComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input() data!: any; // Volume | Chapter
@Input() data!: Volume | Chapter; // Volume | Chapter
/**
* If this is a volume, this will be first chapter for said volume.
*/
chapter!: Chapter;
isChapter = false;
chapters: Chapter[] = [];
seriesVolumes: any[] = [];
isLoadingVolumes = false;
formatKeys = Object.keys(MangaFormat);
/**
* If a cover image update occured.
*/
coverImageUpdate: boolean = false;
isAdmin: boolean = false;
coverImageIndex: number = 0;
/**
* Url of the selected cover
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
/**
* When the API is doing work
*/
coverImageSaveLoading: boolean = false;
imageUrls: Array<string> = [];
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga;
series: Series | undefined = undefined;
bookmarks: PageBookmark[] = [];
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Bookmarks', disabled: false}, {title: 'Info', disabled: false}];
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
ageRating!: string;
get Breakpoint(): typeof Breakpoint {
return Breakpoint;
}
get PersonRole() {
return PersonRole;
}
get LibraryType(): typeof LibraryType {
return LibraryType;
}
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
constructor(public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService) { }
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data);
console.log('isChapter: ', this.isChapter);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
let bookmarkApi;
if (this.isChapter) {
bookmarkApi = this.readerService.getBookmarks(this.chapter.id);
} else {
bookmarkApi = this.readerService.getBookmarksForVolume(this.data.id);
}
bookmarkApi.pipe(take(1)).subscribe(bookmarks => {
this.bookmarks = bookmarks;
});
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
});
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
if (!this.accountService.hasAdminRole(user)) {
this.tabs.find(s => s.title === 'Cover')!.disabled = true;
}
}
});
@ -72,10 +134,11 @@ export class CardDetailsModalComponent implements OnInit {
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
if (this.isChapter) {
this.chapters.push(this.data);
this.chapters.push(this.data as Chapter);
} else if (!this.isChapter) {
this.chapters.push(...this.data?.chapters);
this.chapters.push(...(this.data as Volume).chapters);
}
// TODO: Move this into the backend
this.chapters.sort(this.utilityService.sortChapters);
this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id));
// Try to show an approximation of the reading order for files
@ -83,10 +146,6 @@ export class CardDetailsModalComponent implements OnInit {
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
this.seriesService.getSeries(this.seriesId).subscribe(series => {
this.series = series;
})
}
close() {
@ -106,34 +165,36 @@ export class CardDetailsModalComponent implements OnInit {
}
}
updateCover() {
const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
if (this.utilityService.isChapter(this.data)) {
const chapter = this.utilityService.asChapter(this.data)
chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id);
modalRef.componentInstance.chapter = chapter;
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : this.utilityService.formatChapterName(this.libraryType, false, true)) + chapter.range + '\'s Cover';
} else {
const volume = this.utilityService.asVolume(this.data);
const chapters = volume.chapters;
if (chapters && chapters.length > 0) {
modalRef.componentInstance.chapter = chapters[0];
modalRef.componentInstance.title = 'Select Volume ' + volume.number + '\'s Cover';
}
}
modalRef.closed.subscribe((closeResult: {success: boolean, chapter: Chapter, coverImageUpdate: boolean}) => {
if (closeResult.success) {
this.coverImageUpdate = closeResult.coverImageUpdate;
if (!this.coverImageUpdate) {
this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => {
this.toastr.info('Please refresh in a bit for the cover image to be reflected.');
});
} else {
closeResult.chapter.coverImage = this.imageService.randomize(this.imageService.getChapterCoverImage(closeResult.chapter.id));
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
}
saveCoverImage() {
this.coverImageSaveLoading = true;
const selectedIndex = this.coverImageIndex || 0;
if (selectedIndex > 0) {
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.coverImageUpdate = true;
}
}
});
this.coverImageSaveLoading = false;
}, err => this.coverImageSaveLoading = false);
} else if (this.coverImageLocked === false) {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('Cover image reset');
this.coverImageSaveLoading = false;
this.coverImageUpdate = true;
});
}
}
markChapterAsRead(chapter: Chapter) {
@ -180,4 +241,10 @@ export class CardDetailsModalComponent implements OnInit {
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
}
}
removeBookmark(bookmark: PageBookmark, index: number) {
this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page).subscribe(() => {
this.bookmarks.splice(index, 1);
});
}
}

View file

@ -1,11 +0,0 @@
<div class="modal-header">{{title}}</div>
<div class="modal-body scrollable-modal">
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="cancel()">Cancel</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="loading">Save</button>
</div>

View file

@ -1,65 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Chapter } from 'src/app/_models/chapter';
import { ImageService } from 'src/app/_services/image.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-change-cover-image-modal',
templateUrl: './change-cover-image-modal.component.html',
styleUrls: ['./change-cover-image-modal.component.scss']
})
export class ChangeCoverImageModalComponent implements OnInit {
@Input() chapter!: Chapter;
@Input() title: string = '';
selectedCover: string = '';
imageUrls: Array<string> = [];
coverImageIndex: number = 0;
coverImageLocked: boolean = false;
loading: boolean = false;
constructor(private imageService: ImageService, private uploadService: UploadService, public modal: NgbActiveModal) { }
ngOnInit(): void {
// Randomization isn't needed as this is only the chooser
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
}
cancel() {
this.modal.close({success: false, coverImageUpdate: false})
}
save() {
this.loading = true;
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
}
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
this.loading = false;
}, err => this.loading = false);
} else {
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
this.chapter.coverImageLocked = false;
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
}
}

View file

@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
@ -16,8 +16,8 @@
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="collectionTagForm">
<div class="form-group">
<label for="summary">Summary</label>
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
@ -67,6 +67,6 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-secondary alt" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View file

@ -2,87 +2,326 @@
<div class="modal-header">
<h4 class="modal-title">
{{this.series.name}} Details</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="editSeriesForm">
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="name">Name</label>
<input id="name" class="form-control" formControlName="name" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="sort-name">Sort Name</label>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="localized-name">Localized Name</label>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="author">Author</label>
<input id="author" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="author" type="text">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="name" class="form-label">Name</label>
<div class="input-group {{series.nameLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'nameLocked' }"></ng-container>
<input id="name" class="form-control" formControlName="name" type="text">
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="artist">Artist</label>
<input id="artist" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="artist" type="text">
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="sort-name" class="form-label">Sort Name</label>
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
</div>
</div>
</div>
</div>
<div class="row no-gutters" *ngIf="metadata">
<div class="col-md-6">
<div class="form-group">
<label for="genres">Genres</label>
<input id="genres" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="genres" type="text">
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<label for="localized-name" class="form-label">Localized Name</label>
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="collections">Collections</label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="settings">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
<div class="row g-0" *ngIf="metadata">
<div class="mb-3" style="width: 100%">
<label for="summary" class="form-label">Summary</label>
<div class="input-group {{metadata?.summaryLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</div>
</div>
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</form>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]" *ngIf="metadata">
<a ngbNavLink>{{tabs[1]}}</a>
<ng-template ngbNavContent>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1]}}</a>
<div class="row g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="collections" class="form-label">Collections </label>
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true">
<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 g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="genres" class="form-label">Genres</label>
<app-typeahead (selectedData)="updateGenres($event)" [settings]="genreSettings"
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
(newItemAdded)="metadata.genresLocked = true" (selectedData)="metadata.genresLocked = true">
<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 g-0">
<div class="col-md-12">
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<app-typeahead (selectedData)="updateTags($event)" [settings]="tagsSettings"
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
(newItemAdded)="metadata.tagsLocked = true" (selectedData)="metadata.tagsLocked = true">
<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 g-0">
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="language" class="form-label">Language</label>
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
(newItemAdded)="metadata.languageLocked = true" (selectedData)="metadata.languageLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-lg-4 col-md-12 pe-2">
<div class="mb-3">
<label for="age-rating" class="form-label">Age Rating</label>
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'ageRatingLocked' }"></ng-container>
<select class="form-select"id="age-rating" formControlName="ageRating">
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
<div class="col-lg-4 col-md-12">
<div class="mb-3">
<label for="publication-status" class="form-label">Publication Status</label>
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
<select class="form-select"id="publication-status" formControlName="publicationStatus">
<option *ngFor="let opt of publicationStatuses" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3">
<label for="writer" class="form-label">Writer</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)"
[(locked)]="metadata.writersLocked" (onUnlock)="metadata.writersLocked = false"
(newItemAdded)="metadata.writersLocked = true" (selectedData)="metadata.writersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="cover-artist" class="form-label">Cover Artist</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
[(locked)]="metadata.coverArtistsLocked" (onUnlock)="metadata.coverArtistsLocked = false"
(newItemAdded)="metadata.coverArtistsLocked = true" (selectedData)="metadata.coverArtistsLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="publisher" class="form-label">Publisher</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="penciller" class="form-label">Penciller</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)"
[(locked)]="metadata.pencillersLocked" (onUnlock)="metadata.pencillersLocked = false"
(newItemAdded)="metadata.pencillersLocked = true" (selectedData)="metadata.pencillersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="letterer" class="form-label">Letterer</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)"
[(locked)]="metadata.letterersLocked" (onUnlock)="metadata.letterersLocked = false"
(newItemAdded)="metadata.letterersLocked = true" (selectedData)="metadata.letterersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="inker" class="form-label">Inker</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)"
[(locked)]="metadata.inkersLocked" (onUnlock)="metadata.inkersLocked = false"
(newItemAdded)="metadata.inkersLocked = true" (selectedData)="metadata.inkersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="editor" class="form-label">Editor</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)"
[(locked)]="metadata.editorsLocked" (onUnlock)="metadata.editorsLocked = false"
(newItemAdded)="metadata.editorsLocked = true" (selectedData)="metadata.editorsLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="colorist" class="form-label">Colorist</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)"
[(locked)]="metadata.coloristsLocked" (onUnlock)="metadata.coloristsLocked = false"
(newItemAdded)="metadata.coloristsLocked = true" (selectedData)="metadata.coloristsLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="character" class="form-label">Character</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)"
[(locked)]="metadata.charactersLocked" (onUnlock)="metadata.charactersLocked = false"
(newItemAdded)="metadata.charactersLocked = true" (selectedData)="metadata.charactersLocked = true">
<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="row g-0">
<div class="mb-3">
<label for="translator" class="form-label">Translators</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)"
[(locked)]="metadata.translatorsLocked" (onUnlock)="metadata.translatorsLocked = false"
(newItemAdded)="metadata.translatorsLocked = true" (selectedData)="metadata.translatorsLocked = true">
<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>
</ng-template>
</li>
<li [ngbNavItem]="tabs[3]">
<a ngbNavLink>{{tabs[3]}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
@ -90,11 +329,11 @@
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<li [ngbNavItem]="tabs[4]">
<a ngbNavLink>{{tabs[4]}}</a>
<ng-template ngbNavContent>
<h4>Information</h4>
<div class="row no-gutters mb-2">
<div class="row g-0 mb-2">
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | sentenceCase}}</div>
<div class="col-md-6">Format: <app-tag-badge>{{utilityService.mangaFormat(series.format)}}</app-tag-badge></div>
</div>
@ -103,12 +342,12 @@
<span class="invisible">Loading...</span>
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="media my-4" *ngFor="let volume of seriesVolumes">
<app-image class="mr-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="media-body">
<li class="d-flex my-4" *ngFor="let volume of seriesVolumes">
<app-image class="me-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
<div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Added: {{volume.created | date: 'short'}}
</div>
@ -116,7 +355,7 @@
Last Modified: {{volume.lastModified | date: 'short'}}
</div>
</div>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
View Files
@ -131,7 +370,7 @@
<ul class="list-group mt-2">
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="row g-0">
<div class="col">
Chapter: {{file.chapter}}
</div>
@ -152,8 +391,9 @@
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ml-4 flex-fill'}}"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
@ -162,3 +402,10 @@
</div>
<ng-template #lock let-item="item" let-field="field">
<span class="input-group-text clickable" (click)="unlock(item, field)">
<i class="fa fa-lock" aria-hidden="true"></i>
<span class="visually-hidden">Field is locked</span>
</span>
</ng-template>

View file

@ -2,3 +2,10 @@
max-height: 90vh; // 600px
overflow: auto;
}
.lock-active {
> .input-group-text {
background-color: var(--primary-color);
color: white;
}
}

View file

@ -1,17 +1,24 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Subject } from 'rxjs';
import { forkJoin, Observable, of, Subject } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Genre } from 'src/app/_models/genre';
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 { Person, PersonRole } from 'src/app/_models/person';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/series-metadata';
import { Tag } from 'src/app/_models/tag';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { SeriesService } from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service';
@ -25,17 +32,34 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
@Input() series!: Series;
seriesVolumes: any[] = [];
isLoadingVolumes = false;
/**
* A copy of the series from init. This is used to compare values for name fields to see if lock was modified
*/
initSeries!: Series;
isCollapsed = true;
volumeCollapsed: any = {};
tabs = ['General', 'Cover Image', 'Info'];
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info'];
active = this.tabs[0];
editSeriesForm!: FormGroup;
libraryName: string | undefined = undefined;
private readonly onDestroy = new Subject<void>();
settings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
tags: CollectionTag[] = [];
// Typeaheads
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>} = {};
collectionTagSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
collectionTags: CollectionTag[] = [];
tags: Tag[] = [];
genres: Genre[] = [];
metadata!: SeriesMetadata;
imageUrls: Array<string> = [];
/**
@ -43,18 +67,33 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
*/
selectedCover: string = '';
ageRatings: Array<AgeRatingDto> = [];
publicationStatuses: Array<PublicationStatusDto> = [];
validLanguages: Array<Language> = [];
coverImageReset = false;
get Breakpoint(): typeof Breakpoint {
return Breakpoint;
}
get PersonRole() {
return PersonRole;
}
getPersonsSettings(role: PersonRole) {
return this.peopleSettings[role];
}
constructor(public modal: NgbActiveModal,
private seriesService: SeriesService,
public utilityService: UtilityService,
private fb: FormBuilder,
public imageService: ImageService,
public imageService: ImageService,
private libraryService: LibraryService,
private collectionService: CollectionTagService,
private uploadService: UploadService) { }
private uploadService: UploadService,
private metadataService: MetadataService) { }
ngOnInit(): void {
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
@ -62,33 +101,76 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
this.libraryName = names[this.series.libraryId];
});
this.setupTypeaheadSettings();
this.initSeries = Object.assign({}, this.series);
this.editSeriesForm = this.fb.group({
id: new FormControl(this.series.id, []),
summary: new FormControl('', []),
summary: new FormControl('', []),
name: new FormControl(this.series.name, []),
localizedName: new FormControl(this.series.localizedName, []),
sortName: new FormControl(this.series.sortName, []),
rating: new FormControl(this.series.userRating, []),
genres: new FormControl('', []),
author: new FormControl('', []),
artist: new FormControl('', []),
coverImageIndex: new FormControl(0, []),
coverImageLocked: new FormControl(this.series.coverImageLocked, [])
coverImageLocked: new FormControl(this.series.coverImageLocked, []),
ageRating: new FormControl('', []),
publicationStatus: new FormControl('', []),
language: new FormControl('', []),
});
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
});
this.metadataService.getAllPublicationStatus().subscribe(statuses => {
this.publicationStatuses = statuses;
});
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
this.validLanguages = validLanguages;
})
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
if (metadata) {
this.metadata = metadata;
this.settings.savedData = metadata.collectionTags;
this.tags = metadata.collectionTags;
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
this.setupTypeaheads();
this.editSeriesForm.get('summary')?.patchValue(this.metadata.summary);
this.editSeriesForm.get('ageRating')?.patchValue(this.metadata.ageRating);
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.nameLocked = true;
});
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.sortNameLocked = true;
});
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.localizedNameLocked = true;
});
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.summaryLocked = true;
this.metadata.summary = val;
});
this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.ageRating = parseInt(val + '', 10);
this.metadata.ageRatingLocked = true;
});
this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.publicationStatus = parseInt(val + '', 10);
this.metadata.publicationStatusLocked = true;
});
}
});
@ -114,22 +196,194 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.onDestroy.complete();
}
setupTypeaheadSettings() {
this.settings.minCharacters = 0;
this.settings.multiple = true;
this.settings.id = 'collections';
this.settings.unique = true;
this.settings.addIfNonExisting = true;
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.settings.compareFn(items, filter)));
this.settings.addTransformFn = ((title: string) => {
setupTypeaheads() {
forkJoin([
this.setupCollectionTagsSettings(),
this.setupTagSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
this.setupLanguageTypeahead()
]).subscribe(results => {
this.collectionTags = this.metadata.collectionTags;
});
}
setupCollectionTagsSettings() {
this.collectionTagSettings.minCharacters = 0;
this.collectionTagSettings.multiple = true;
this.collectionTagSettings.id = 'collections';
this.collectionTagSettings.unique = true;
this.collectionTagSettings.addIfNonExisting = true;
this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter)));
this.collectionTagSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
});
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
// console.log('compareFN:')
// console.log('options: ', options);
// console.log('filter: ', filter);
// console.log('results: ', options.filter(m => this.utilityService.filter(m.title, filter)));
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.settings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.title === b.title;
}
if (this.metadata.collectionTags) {
this.collectionTagSettings.savedData = this.metadata.collectionTags;
}
return of(true);
}
setupTagSettings() {
this.tagsSettings.minCharacters = 0;
this.tagsSettings.multiple = true;
this.tagsSettings.id = 'tags';
this.tagsSettings.unique = true;
this.tagsSettings.showLocked = true;
this.tagsSettings.addIfNonExisting = true;
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags()
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
this.tagsSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => {
return a.id == b.id;
}
if (this.metadata.tags) {
this.tagsSettings.savedData = this.metadata.tags;
}
return of(true);
}
setupGenreTypeahead() {
this.genreSettings.minCharacters = 0;
this.genreSettings.multiple = true;
this.genreSettings.id = 'genres';
this.genreSettings.unique = true;
this.genreSettings.showLocked = true;
this.genreSettings.addIfNonExisting = true;
this.genreSettings.fetchFn = (filter: string) => {
return this.metadataService.getAllGenres()
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
};
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => {
return a.title == b.title;
}
this.genreSettings.addTransformFn = ((title: string) => {
return {id: 0, title: title };
});
if (this.metadata.genres) {
this.genreSettings.savedData = this.metadata.genres;
}
return of(true);
}
updateFromPreset(id: string, presetField: Array<Person> | 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 => {
const persetIds = presetField.map(p => p.id);
personSettings.savedData = people.filter(person => persetIds.includes(person.id));
this.peopleSettings[role] = personSettings;
this.updatePerson(personSettings.savedData as Person[], role);
return true;
}));
} else {
this.peopleSettings[role] = personSettings;
return of(true);
}
}
setupLanguageTypeahead() {
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
if (this.metadata.language) {
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
}
return of(true);
}
setupPersonTypeahead() {
this.peopleSettings = {};
return forkJoin([
this.updateFromPreset('writer', this.metadata.writers, PersonRole.Writer),
this.updateFromPreset('character', this.metadata.characters, PersonRole.Character),
this.updateFromPreset('colorist', this.metadata.colorists, PersonRole.Colorist),
this.updateFromPreset('cover-artist', this.metadata.coverArtists, PersonRole.CoverArtist),
this.updateFromPreset('editor', this.metadata.editors, PersonRole.Editor),
this.updateFromPreset('inker', this.metadata.inkers, PersonRole.Inker),
this.updateFromPreset('letterer', this.metadata.letterers, PersonRole.Letterer),
this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller),
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
]).pipe(map(results => {
return of(true);
}));
}
fetchPeople(role: PersonRole, filter: string) {
return this.metadataService.getAllPeople().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.showLocked = true;
personSettings.unique = true;
personSettings.addIfNonExisting = true;
personSettings.id = id;
personSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role };
});
return personSettings;
}
close() {
@ -150,23 +404,83 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
save() {
const model = this.editSeriesForm.value;
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
const apis = [
this.seriesService.updateSeries(model),
this.seriesService.updateMetadata(this.metadata, this.tags)
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
];
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
const nameFieldsDirty = this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty;
const nameFieldLockChanged = this.series.nameLocked !== this.initSeries.nameLocked || this.series.sortNameLocked !== this.initSeries.sortNameLocked || this.series.localizedNameLocked !== this.initSeries.localizedNameLocked;
if (nameFieldsDirty || nameFieldLockChanged || this.coverImageReset) {
model.nameLocked = this.series.nameLocked;
model.sortNameLocked = this.series.sortNameLocked;
model.localizedNameLocked = this.series.localizedNameLocked;
apis.push(this.seriesService.updateSeries(model));
}
if (selectedIndex > 0) {
if (selectedIndex > 0 && this.selectedCover !== '') {
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
}
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
});
}
updateCollections(tags: CollectionTag[]) {
this.collectionTags = tags;
}
updateTags(tags: Tag[]) {
this.tags = tags;
this.metadata.tags = tags;
}
updateGenres(genres: Genre[]) {
this.genres = genres;
this.metadata.genres = genres;
}
updateLanguage(language: Language) {
this.metadata.language = language.isoCode;
}
updatePerson(persons: Person[], role: PersonRole) {
switch (role) {
case PersonRole.CoverArtist:
this.metadata.coverArtists = persons;
break;
case PersonRole.Character:
this.metadata.characters = persons;
break;
case PersonRole.Colorist:
this.metadata.colorists = persons;
break;
case PersonRole.Editor:
this.metadata.editors = persons;
break;
case PersonRole.Inker:
this.metadata.inkers = persons;
break;
case PersonRole.Letterer:
this.metadata.letterers = persons;
break;
case PersonRole.Penciller:
this.metadata.pencillers = persons;
break;
case PersonRole.Publisher:
this.metadata.publishers = persons;
break;
case PersonRole.Writer:
this.metadata.writers = persons;
break;
case PersonRole.Translator:
this.metadata.translators = persons;
}
}
updateSelectedIndex(index: number) {
@ -180,9 +494,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
}
handleReset() {
this.coverImageReset = true;
this.editSeriesForm.patchValue({
coverImageLocked: false
});
}
unlock(b: any, field: string) {
if (b) {
b[field] = !b[field];
}
}
}

View file

@ -6,12 +6,12 @@
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-right" *ngIf="series != undefined">
<span class="card-actions float-end" *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>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>

View file

@ -2,7 +2,7 @@
<div class="d-flex justify-content-around align-items-center">
<span class="highlight"><i class="fa fa-check" aria-hidden="true"></i>&nbsp;{{bulkSelectionService.totalSelections()}} selected</span>
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
<span id="bulk-actions-header" class="sr-only">Bulk Actions</span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i>&nbsp;Deselect All</button>
</div>
</div>

View file

@ -1,15 +1,9 @@
@use "../../../theme/colors";
.bulk-select {
background-color: colors.$dark-form-background-no-opacity;
border-bottom: 2px solid colors.$primary-color;
color: white;
}
.btn-icon {
color: white;
background-color: var(--navbar-bg-color);
border-bottom: 2px solid var(--primary-color);
color: var(--navbar-text-color);
}
.highlight {
color: colors.$primary-color !important;
color: var(--primary-color) !important;
}

View file

@ -1,377 +1,37 @@
<div class="container-fluid" style="padding-top: 10px">
<div class="row no-gutters pb-2">
<div class="col mr-auto">
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
<div class="col me-auto">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span>{{header}}&nbsp;
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</span>
<span *ngIf="header !== undefined && header.length > 0">
{{header}}&nbsp;
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</span>
</h2>
</div>
<button *ngIf="!filteringDisabled" 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="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">&times;</span>
</button>
</h2>
</div>
<div body class="drawer-body">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</app-drawer>
</div>
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<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>&nbsp;<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>&nbsp;<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'">&#9733;</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>
<div class="row no-gutters">
<div class="col-auto" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<div class="row g-0 mt-2 mb-2">
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
<p *ngIf="items.length === 0 && !isLoading">
There is no data
</p>
</div>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
</div>
<ng-template #paginationTemplate let-id="id">
<div class="d-flex justify-content-center" *ngIf="pagination && items.length > 0">
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[maxSize]="8"
@ -384,11 +44,11 @@
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
<div class="form-group d-flex flex-nowrap px-2">
<div class="d-flex flex-nowrap px-2">
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label mr-2 ml-1"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"
@ -408,7 +68,7 @@
</div>
</li>
</ng-template>
</ngb-pagination>
</div>
</ng-template>

View file

@ -1,9 +0,0 @@
@use '../../../theme/colors';
.star {
font-size: 1.5rem;
color: colors.$rating-empty;
}
.filled {
color: colors.$rating-filled;
}

View file

@ -1,50 +1,16 @@
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 { Subject } from 'rxjs';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
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 { Person, PersonRole } from 'src/app/_models/person';
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter';
import { Tag } from 'src/app/_models/tag';
import { FilterEvent, FilterItem, SeriesFilter, SortField } from 'src/app/_models/series-filter';
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;
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',
@ -54,7 +20,7 @@ export class FilterSettings {
export class CardDetailLayoutComponent implements OnInit, OnDestroy {
@Input() header: string = '';
@Input() isLoading: boolean = false;
@Input() isLoading: boolean = false;
@Input() items: any[] = [];
@Input() pagination!: Pagination;
/**
@ -70,99 +36,33 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
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;
// Filter Code
@Input() filterOpen!: EventEmitter<boolean>;
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) {
constructor(private seriesService: SeriesService) {
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.updateApplied}`;
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}_${item?.libraryId}`;
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
}
this.setupTypeaheads();
}
ngOnDestroy() {
@ -170,302 +70,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
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).pipe(map(items => this.formatSettings.compareFn(items, filter)));
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.formatSettings.singleCompareFn = (a: FilterItem<MangaFormat>, b: FilterItem<MangaFormat>) => {
return a.title == b.title;
}
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()
.pipe(map(items => this.librarySettings.compareFn(items, filter)));
};
this.librarySettings.compareFn = (options: Library[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
this.librarySettings.singleCompareFn = (a: Library, b: Library) => {
return a.name == b.name;
}
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)
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
};
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => {
return a.title == b.title;
}
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) => this.metadataService.getAllAgeRatings(this.filter.libraries)
.pipe(map(items => this.ageRatingSettings.compareFn(items, filter)));
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.ageRatingSettings.singleCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
return a.title == b.title;
}
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) => this.metadataService.getAllPublicationStatus(this.filter.libraries)
.pipe(map(items => this.publicationStatusSettings.compareFn(items, filter)));
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.publicationStatusSettings.singleCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => {
return a.title == b.title;
}
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.compareFn = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries)
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => {
return a.id == b.id;
}
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.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.singleCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
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.compareFn = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags()
.pipe(map(items => this.collectionSettings.compareFn(items, filter)));
this.collectionSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.id == b.id;
}
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) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
personSettings.singleCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
return personSettings;
}
onPageChange(page: number) {
this.pageChange.emit(this.pagination);
}
@ -485,117 +89,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
}
}
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({filter: this.filter, isFirst: this.updateApplied === 0});
applyMetadataFilter(event: FilterEvent) {
this.applyFilter.emit(event);
this.updateApplied++;
}

View file

@ -12,7 +12,7 @@
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="sr-only" role="status">
<span class="visually-hidden" role="status">
{{download.progress}}% downloaded
</span>
</span>
@ -23,25 +23,26 @@
<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" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
<div class="count" *ngIf="count > 1">
<span class="badge badge-primary">{{count}}</span>
<span class="badge bg-primary">{{count}}</span>
</div>
<div class="card-overlay"></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]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="sr-only">(promoted)</span>
<span class="visually-hidden">(promoted)</span>
</span>
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="visually-hidden">{{utilityService.mangaFormat(format)}}</span>
&nbsp;{{title}}
</span>
<span class="card-actions float-right">
<span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
</span>
</div>

View file

@ -1,4 +1,4 @@
@use '../../../theme/colors';
$triangle-size: 30px;
$image-height: 230px;
@ -7,7 +7,7 @@ $image-width: 160px;
.error-banner {
width: 160px;
height: 18px;
background-color: colors.$error-color;
background-color: var(--toast-error-bg-color);
font-size: 12px;
color: white;
text-transform: uppercase;
@ -19,12 +19,16 @@ $image-width: 160px;
}
.card {
margin: 5px;
max-width: $image-width;
cursor: pointer;
padding-left: 0px;
padding-right: 0px;
box-sizing: border-box;
position: relative;
background-color: var(--card-bg-color);
color: var(--card-text-color);
border-color: var(--card-border-color);
}
.card-title {
@ -39,7 +43,7 @@ $image-width: 160px;
}
.selected-highlight {
outline: 2px solid colors.$primary-color;
outline: 2px solid var(--primary-color);
}
@ -52,7 +56,7 @@ $image-width: 160px;
height: 5px;
.progress {
color: colors.$primary-color;
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
@ -73,7 +77,7 @@ $image-width: 160px;
height: 0;
border-style: solid;
border-width: 0 $triangle-size $triangle-size 0;
border-color: transparent colors.$primary-color transparent transparent;
border-color: transparent var(--primary-color) transparent transparent;
}
@ -92,6 +96,7 @@ $image-width: 160px;
input[type="checkbox"] {
width: 20px;
height: 20px;
color: var(--checkbox-bg-color);
}
}
@ -106,10 +111,12 @@ $image-width: 160px;
.bulk-mode {
visibility: visible;
z-index: 110;
}
.overlay-item {
visibility: visible;
z-index: 100;
}
}
@ -142,3 +149,13 @@ $image-width: 160px;
text-decoration: none;
margin-top: 0px;
}
.card-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 230px;
z-index: 10;
transition: all 0.2s;
}

View file

@ -5,9 +5,7 @@ import { LibraryCardComponent } from './library-card/library-card.component';
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
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, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -24,6 +22,7 @@ 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';
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
@ -35,7 +34,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
ChangeCoverImageModalComponent,
BookmarksModalComponent,
CardActionablesComponent,
CardDetailLayoutComponent,
@ -52,16 +50,20 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
ReactiveFormsModule,
FormsModule, // EditCollectionsModal
PipeModule,
SharedModule,
TypeaheadModule,
TypeaheadModule, // edit series modal
MetadataFilterModule,
NgbNavModule,
NgbTooltipModule, // Card item
NgbCollapseModule,
NgbRatingModule,
NgbNavModule, //Series Detail
LazyLoadImageModule,
NgbPaginationModule, // CardDetailLayoutComponent
NgbDropdownModule,
NgbProgressbarModule,
@ -77,7 +79,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
ChangeCoverImageModalComponent,
BookmarksModalComponent,
CardActionablesComponent,
CardDetailLayoutComponent,

View file

@ -1,118 +1,102 @@
<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">
Id: {{chapter.id}}
<ng-container>
<span *ngIf="chapter.writers.length === 0 && chapter.coverArtists.length === 0
&& chapter.pencillers.length === 0 && chapter.inkers.length === 0
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
&& chapter.editors.length === 0 && chapter.publishers.length === 0
&& chapter.characters.length === 0 && chapter.translators.length === 0">
No metadata available
</span>
<div class="row g-0">
<div class="col-auto mt-2" *ngIf="chapter.writers && chapter.writers.length > 0">
<h6>Writers</h6>
<app-badge-expander [items]="chapter.writers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
</div>
<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}}">
<app-image class="mr-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</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>&nbsp;
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</span> -->
<span class="badge badge-primary badge-pill">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
<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 class="col-auto mt-2" *ngIf="chapter.coverArtists && chapter.coverArtists.length > 0">
<h6>Cover Artists</h6>
<app-badge-expander [items]="chapter.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.pencillers && chapter.pencillers.length > 0">
<h6>Pencillers</h6>
<app-badge-expander [items]="chapter.pencillers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.inkers && chapter.inkers.length > 0">
<h6>Inkers</h6>
<app-badge-expander [items]="chapter.inkers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.colorists && chapter.colorists.length > 0">
<h6>Colorists</h6>
<app-badge-expander [items]="chapter.colorists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
</li>
</ul>
</ng-container>
<!--
<div class="col-auto mt-2" *ngIf="chapter.letterers && chapter.letterers.length > 0">
<h6>Letterers</h6>
<app-badge-expander [items]="chapter.letterers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<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="col-auto mt-2" *ngIf="chapter.editors && chapter.editors.length > 0">
<h6>Editors</h6>
<app-badge-expander [items]="chapter.editors">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="row no-gutters">
<div class="col">
Id: {{chapter.id}}
</div>
<div class="col">
Pages: {{chapter.pages}}
</div>
</div>
<div class="col-auto mt-2" *ngIf="chapter.publishers && chapter.publishers.length > 0">
<h6>Publishers</h6>
<app-badge-expander [items]="chapter.publishers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="row no-gutters">
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
Added: {{(chapter.created | date: 'short') || '-'}}
<div class="col-auto mt-2" *ngIf="chapter.characters && chapter.characters.length > 0">
<h6>Characters</h6>
<app-badge-expander [items]="chapter.characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.translators && chapter.translators.length > 0">
<h6>Translators</h6>
<app-badge-expander [items]="chapter.translators">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
</div>
<div class="col">
Pages: {{chapter.pages}}
</div>
</div>
</div> -->
</ng-container>
</ng-container>

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