Localization - First Pass (#2174)
* Started designing the backend localization service * Worked in Transloco for initial PoC * Worked in Transloco for initial PoC * Translated the login screen * translated dashboard screen * Started work on the backend * Fixed a logic bug * translated edit-user screen * Hooked up the backend for having a locale property. * Hooked up the ability to view the available locales and switch to them. * Made the localization service languages be derived from what's in langs/ directory. * Fixed up localization switching * Switched when we check for a license on UI bootstrap * Tweaked some code * Fixed the bug where dashboard wasn't loading and made it so language switching is working. * Fixed a bug on dashboard with languagePath * Converted user-scrobble-history.component.html * Converted spoiler.component.html * Converted review-series-modal.component.html * Converted review-card-modal.component.html * Updated the readme * Translated using Weblate (English) Currently translated at 100.0% (54 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/ * Converted review-card.component.html * Deleted dead component * Converted want-to-read.component.html * Added translation using Weblate (Korean) * Translated using Weblate (Spanish) Currently translated at 40.7% (22 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/ * Translated using Weblate (Korean) Currently translated at 62.9% (34 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/ * Converted user-preferences.component.html * Translated using Weblate (Korean) Currently translated at 92.5% (50 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/ * Converted user-holds.component.html * Converted theme-manager.component.html * Converted restriction-selector.component.html * Converted manage-devices.component.html * Converted edit-device.component.html * Converted change-password.component.html * Converted change-email.component.html * Converted change-age-restriction.component.html * Converted api-key.component.html * Converted anilist-key.component.html * Converted typeahead.component.html * Converted user-stats-info-cards.component.html * Converted user-stats.component.html * Converted top-readers.component.html * Converted some pipes and ensure translation is loaded before the app. * Finished all but one pipe for localization * Converted directory-picker.component.html * Converted library-access-modal.component.html * Converted a few components * Converted a few components * Converted a few components * Converted a few components * Converted a few components * Merged weblate in * ... -> … update * Updated the readme * Updateded all fonts to be woff2 * Cleaned up some strings to increase re-use * Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita. * Converted Series detail * Lots more converted * Lots more converted & hooked up the ability to flatten during prod build the language files. * Lots more converted * Lots more converted & fixed a bunch of broken pipes due to inject() * Lots more converted * Lots more converted * Lots more converted & fixed some bad keys * Lots more converted * Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection * Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission * Fixed a stupid build issue again * Started adding errors for interceptor and backend. * Finished off manga-reader * More translations * Few fixes * Fixed a bug where character tag badges weren't showing the name on chapter info * All components are translated * All toasts are translated * All confirm/alerts are translated * Trying something new for the backend * Migrated the localization strings for the backend into a new file. * Updated the localization service to be able to do backend localization with fallback to english. * Cleaned up some external reviews code to reduce looping * Localized AccountController.cs * 60% done with controllers * All controllers are done * All KavitaExceptions are covered * Some shakeout fixes * Prep for initial merge * Everything is done except options and basic shakeout proves response times are good. Unit tests are broken. * Fixed up the unit tests * All unit tests are now working * Removed some quantifier * I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase. --------- Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: majora2007 <kavitareader@gmail.com> Co-authored-by: expertjun <jtrobin@naver.com> Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
parent
670bf82c38
commit
3b23d63234
389 changed files with 13652 additions and 7925 deletions
358
UI/Web/package-lock.json
generated
358
UI/Web/package-lock.json
generated
|
@ -23,6 +23,11 @@
|
|||
"@iplab/ngx-file-upload": "^16.0.1",
|
||||
"@microsoft/signalr": "^7.0.9",
|
||||
"@ng-bootstrap/ng-bootstrap": "^15.1.0",
|
||||
"@ngneat/transloco": "^4.3.0",
|
||||
"@ngneat/transloco-locale": "^4.1.0",
|
||||
"@ngneat/transloco-persist-lang": "^4.0.0",
|
||||
"@ngneat/transloco-persist-translations": "^4.0.0",
|
||||
"@ngneat/transloco-preload-langs": "^4.0.1",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@swimlane/ngx-charts": "^20.1.2",
|
||||
"@tweenjs/tween.js": "^21.0.0",
|
||||
|
@ -53,6 +58,7 @@
|
|||
"@angular-eslint/template-parser": "^16.1.0",
|
||||
"@angular/cli": "^16.1.5",
|
||||
"@angular/compiler-cli": "^16.1.6",
|
||||
"@ngneat/transloco-optimize": "^3.0.2",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/node": "^20.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
|
@ -3169,6 +3175,120 @@
|
|||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-4.3.0.tgz",
|
||||
"integrity": "sha512-KUhGvp1ki+jvrM2PO27Tgzme1HkFmvDgS+7VyGxHta35wZEyoH6/r/EAXvfurPeYgaP6IaEMhUvAVT1WDgYwUg==",
|
||||
"dependencies": {
|
||||
"@ngneat/transloco-utils": "3.0.5",
|
||||
"flat": "5.0.2",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"ora": "^5.4.1",
|
||||
"replace-in-file": "^6.2.0",
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=13.0.0",
|
||||
"fs-extra": ">=9.1.0",
|
||||
"glob": ">=7.1.7",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-locale": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-locale/-/transloco-locale-4.1.0.tgz",
|
||||
"integrity": "sha512-xtev6RXEPXh3kJ/xR/aq52z3ZVlv2wCGfc2kT+LQhLsW045zT0e5PJPHjO/xGNmp+T/Z8Axu1h08KwRQUW4Jyg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=13.0.0",
|
||||
"@ngneat/transloco": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-optimize": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-optimize/-/transloco-optimize-3.0.2.tgz",
|
||||
"integrity": "sha512-yCRJGofjIZSCcEf38UEmYkQ3Ez38PKtx88CC2FgbcLgmkdkUB2/xVeRtUN6f0nNt2bJSgMtJvwm7BrttVIACDg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"command-line-args": "^5.2.0",
|
||||
"flat": "^5.0.2",
|
||||
"glob": "^7.1.7"
|
||||
},
|
||||
"bin": {
|
||||
"transloco-optimize": "src/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-optimize/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-persist-lang": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-lang/-/transloco-persist-lang-4.0.0.tgz",
|
||||
"integrity": "sha512-OpYph1obkcB9clC5JGUMkD/Bb8n/jLYkqYUEOHf7/Y/0LcJpLiSTydN99+UhTND03uKAzDcP0WaQvAZaPnXxrw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=13.0.0",
|
||||
"@ngneat/transloco": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-persist-translations": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-translations/-/transloco-persist-translations-4.0.0.tgz",
|
||||
"integrity": "sha512-PeU8JLZbxdmD4JCjRWTbtL26P2yXhjwFs7Tol4sKXsHYBU1VAf7hLshxcN5PWDpkF9rVjcroOZHFNHyRcqR5jA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=13.0.0",
|
||||
"@ngneat/transloco": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-preload-langs": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-preload-langs/-/transloco-preload-langs-4.0.1.tgz",
|
||||
"integrity": "sha512-CcFQSHs/cU6PQ8vq5EVR9XuMS824KsSuNK3jkNLBGrciP09d59yRbRhhNQDWqynY5lNQ1qUtN7djv+9bNa/phQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=13.0.0",
|
||||
"@ngneat/transloco": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngneat/transloco-utils": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@ngneat/transloco-utils/-/transloco-utils-3.0.5.tgz",
|
||||
"integrity": "sha512-Xn9GaLUocXSPMhErNHbUyoloDm9sb+JaYszZJFL9F8em6frPQDSJxcYk9pV0caWpAU8INlksJSYgx1LXAH18mw==",
|
||||
"dependencies": {
|
||||
"cosmiconfig": "^8.1.3",
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngtools/webpack": {
|
||||
"version": "16.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.1.5.tgz",
|
||||
|
@ -5180,6 +5300,15 @@
|
|||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/array-back": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz",
|
||||
|
@ -5360,14 +5489,12 @@
|
|||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
@ -5411,7 +5538,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
|
@ -5506,7 +5632,6 @@
|
|||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
@ -5558,7 +5683,6 @@
|
|||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
@ -5651,7 +5775,6 @@
|
|||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
@ -5761,7 +5884,6 @@
|
|||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"restore-cursor": "^3.1.0"
|
||||
},
|
||||
|
@ -5773,7 +5895,6 @@
|
|||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz",
|
||||
"integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
|
@ -5805,7 +5926,6 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
|
||||
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
|
@ -5864,6 +5984,21 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/command-line-args": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
|
||||
"integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"array-back": "^3.1.0",
|
||||
"find-replace": "^3.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"typical": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
@ -5930,8 +6065,7 @@
|
|||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||
},
|
||||
"node_modules/connect-history-api-fallback": {
|
||||
"version": "2.0.0",
|
||||
|
@ -6119,7 +6253,6 @@
|
|||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz",
|
||||
"integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
@ -6136,14 +6269,12 @@
|
|||
"node_modules/cosmiconfig/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
|
@ -6586,7 +6717,6 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"clone": "^1.0.2"
|
||||
},
|
||||
|
@ -6966,7 +7096,6 @@
|
|||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
|
@ -7800,6 +7929,18 @@
|
|||
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/find-replace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
|
||||
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"array-back": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
|
@ -7817,7 +7958,6 @@
|
|||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
|
||||
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"flat": "cli.js"
|
||||
}
|
||||
|
@ -7972,8 +8112,7 @@
|
|||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
|
@ -8575,7 +8714,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
@ -8659,7 +8797,6 @@
|
|||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
|
@ -8675,7 +8812,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
|
@ -8702,7 +8838,6 @@
|
|||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
|
@ -8711,8 +8846,7 @@
|
|||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "4.1.1",
|
||||
|
@ -8889,8 +9023,7 @@
|
|||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
|
||||
},
|
||||
"node_modules/is-bigint": {
|
||||
"version": "1.0.4",
|
||||
|
@ -9017,7 +9150,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
|
||||
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
@ -9195,7 +9327,6 @@
|
|||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
@ -9554,8 +9685,7 @@
|
|||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
|
@ -9821,6 +9951,12 @@
|
|||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
|
@ -9832,6 +9968,11 @@
|
|||
"resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz",
|
||||
"integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ=="
|
||||
},
|
||||
"node_modules/lodash.kebabcase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
|
||||
"integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
@ -9842,7 +9983,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"is-unicode-supported": "^0.1.0"
|
||||
|
@ -9858,7 +9998,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
|
@ -9873,7 +10012,6 @@
|
|||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
@ -9889,7 +10027,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
|
@ -9900,14 +10037,12 @@
|
|||
"node_modules/log-symbols/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/log-symbols/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
@ -9916,7 +10051,6 @@
|
|||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
@ -10108,7 +10242,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
@ -10142,7 +10275,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
|
@ -11166,7 +11298,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
|
@ -11175,7 +11306,6 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
},
|
||||
|
@ -11233,7 +11363,6 @@
|
|||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
|
||||
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
|
@ -11256,7 +11385,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
|
@ -11271,7 +11399,6 @@
|
|||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
@ -11287,7 +11414,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
|
@ -11298,14 +11424,12 @@
|
|||
"node_modules/ora/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/ora/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
@ -11314,7 +11438,6 @@
|
|||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
@ -11446,7 +11569,6 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
|
@ -11458,7 +11580,6 @@
|
|||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
|
@ -11475,8 +11596,7 @@
|
|||
"node_modules/parse-json/node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
|
||||
},
|
||||
"node_modules/parse-node-version": {
|
||||
"version": "1.0.1",
|
||||
|
@ -11547,7 +11667,6 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
@ -11602,7 +11721,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
@ -12022,7 +12140,6 @@
|
|||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
|
@ -12144,6 +12261,105 @@
|
|||
"jsesc": "bin/jsesc"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-6.3.5.tgz",
|
||||
"integrity": "sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"glob": "^7.2.0",
|
||||
"yargs": "^17.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"replace-in-file": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/replace-in-file/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
@ -12235,7 +12451,6 @@
|
|||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
|
||||
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"onetime": "^5.1.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
|
@ -12247,8 +12462,7 @@
|
|||
"node_modules/restore-cursor/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
|
@ -12367,7 +12581,6 @@
|
|||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
@ -13033,7 +13246,6 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
|
@ -13667,6 +13879,15 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typical": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
|
||||
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
|
||||
|
@ -13799,8 +14020,7 @@
|
|||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
|
@ -13937,7 +14157,6 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
|
@ -14555,8 +14774,7 @@
|
|||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.9",
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"prod": "ng build --configuration production --aot --output-hashing=all",
|
||||
"transloco:optimize": "transloco-optimize dist/assets/langs",
|
||||
"prod": "ng build --configuration production --aot --output-hashing=all && npm run transloco:optimize",
|
||||
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
|
@ -27,6 +28,11 @@
|
|||
"@iplab/ngx-file-upload": "^16.0.1",
|
||||
"@microsoft/signalr": "^7.0.9",
|
||||
"@ng-bootstrap/ng-bootstrap": "^15.1.0",
|
||||
"@ngneat/transloco": "^4.3.0",
|
||||
"@ngneat/transloco-locale": "^4.1.0",
|
||||
"@ngneat/transloco-persist-lang": "^4.0.0",
|
||||
"@ngneat/transloco-persist-translations": "^4.0.0",
|
||||
"@ngneat/transloco-preload-langs": "^4.0.1",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@swimlane/ngx-charts": "^20.1.2",
|
||||
"@tweenjs/tween.js": "^21.0.0",
|
||||
|
@ -57,6 +63,7 @@
|
|||
"@angular-eslint/template-parser": "^16.1.0",
|
||||
"@angular/cli": "^16.1.5",
|
||||
"@angular/compiler-cli": "^16.1.6",
|
||||
"@ngneat/transloco-optimize": "^3.0.2",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/node": "^20.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
|
|
|
@ -4,22 +4,24 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { Observable } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(private accountService: AccountService, private toastr: ToastrService) {}
|
||||
constructor(private accountService: AccountService, private toastr: ToastrService,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
canActivate(): Observable<boolean> {
|
||||
// this automaticallys subs due to being router guard
|
||||
// this automatically subs due to being router guard
|
||||
return this.accountService.currentUser$.pipe(take(1),
|
||||
map((user) => {
|
||||
if (user && this.accountService.hasAdminRole(user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.toastr.error('You are not authorized to view this page.');
|
||||
|
||||
this.toastr.error(this.translocoService.translate('toasts.unauthorized-1'));
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
|
|
@ -4,13 +4,17 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { Observable } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
public urlKey: string = 'kavita--auth-intersection-url';
|
||||
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
|
||||
constructor(private accountService: AccountService,
|
||||
private router: Router,
|
||||
private toastr: ToastrService,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
canActivate(): Observable<boolean> {
|
||||
return this.accountService.currentUser$.pipe(take(1),
|
||||
|
@ -18,8 +22,10 @@ export class AuthGuard implements CanActivate {
|
|||
if (user) {
|
||||
return true;
|
||||
}
|
||||
if (this.toastr.toasts.filter(toast => toast.message === 'Unauthorized' || toast.message === 'You are not authorized to view this page.').length === 0) {
|
||||
this.toastr.error('You are not authorized to view this page.');
|
||||
const errorMessage = this.translocoService.translate('toasts.unauthorized-1');
|
||||
const errorMessage2 = this.translocoService.translate('toasts.unauthorized-2');
|
||||
if (this.toastr.toasts.filter(toast => toast.message === errorMessage2 || toast.message === errorMessage).length === 0) {
|
||||
this.toastr.error(errorMessage);
|
||||
}
|
||||
localStorage.setItem(this.urlKey, window.location.pathname);
|
||||
this.router.navigateByUrl('/login');
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {inject, Injectable} from '@angular/core';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
|
@ -10,10 +10,13 @@ import { Router } from '@angular/router';
|
|||
import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
|
||||
constructor(private router: Router, private toastr: ToastrService,
|
||||
private accountService: AccountService,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
|
@ -38,8 +41,8 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
break;
|
||||
default:
|
||||
// Don't throw multiple Something unexpected went wrong
|
||||
if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') {
|
||||
this.toastr.error('Something unexpected went wrong.');
|
||||
if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.' && this.toastr.previousToastMessage !== 'errors.generic') {
|
||||
this.toast('errors.generic');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -81,36 +84,36 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
console.error('error:', error);
|
||||
if (error.statusText === 'Bad Request') {
|
||||
if (error.error instanceof Blob) {
|
||||
this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status);
|
||||
this.toast('errors.download', error.status);
|
||||
return;
|
||||
}
|
||||
this.toastr.error(error.error, error.status + ' Error');
|
||||
this.toast(error.error, this.translocoService.translate('errors.error-code', {num: error.status}));
|
||||
} else {
|
||||
this.toastr.error(error.statusText === 'OK' ? error.error : error.statusText, error.status + ' Error');
|
||||
this.toast(error.statusText === 'OK' ? error.error : error.statusText, this.translocoService.translate('errors.error-code', {num: error.status}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotFound(error: any) {
|
||||
this.toastr.error('That url does not exist.');
|
||||
this.toast('errors.not-found');
|
||||
}
|
||||
|
||||
private handleServerException(error: any) {
|
||||
const err = error.error;
|
||||
if (err.hasOwnProperty('message') && err.message.trim() !== '') {
|
||||
if (err.message != 'User is not authenticated') {
|
||||
if (err.message != 'User is not authenticated' && error.message !== 'errors.user-not-auth') {
|
||||
console.error('500 error: ', error);
|
||||
}
|
||||
this.toastr.error(err.message);
|
||||
this.toast(err.message);
|
||||
} else if (error.hasOwnProperty('message') && error.message.trim() !== '') {
|
||||
if (error.message != 'User is not authenticated') {
|
||||
if (error.message !== 'User is not authenticated' && error.message !== 'errors.user-not-auth') {
|
||||
console.error('500 error: ', error);
|
||||
}
|
||||
// This just throws duplicate errors for no reason
|
||||
//this.toastr.error(error.message);
|
||||
//this.toast(error.message);
|
||||
}
|
||||
else {
|
||||
this.toastr.error('There was an unknown critical error.');
|
||||
this.toast('errors.unknown-crit');
|
||||
console.error('500 error:', error);
|
||||
}
|
||||
}
|
||||
|
@ -125,4 +128,14 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
|
||||
this.accountService.logout();
|
||||
}
|
||||
|
||||
// Assume the title is already translated
|
||||
private toast(message: string, title?: string) {
|
||||
if (message.startsWith('errors.')) {
|
||||
this.toastr.error(this.translocoService.translate(message), title);
|
||||
} else {
|
||||
this.toastr.error(message, title);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -23,4 +23,8 @@
|
|||
* Inner HTML
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* Key for translation
|
||||
*/
|
||||
translationKey: string;
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ export interface Preferences {
|
|||
noTransitions: boolean;
|
||||
collapseSeriesRelationships: boolean;
|
||||
shareReviews: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||
|
|
|
@ -33,6 +33,7 @@ export class AccountService {
|
|||
baseUrl = environment.apiUrl;
|
||||
userKey = 'kavita-user';
|
||||
public lastLoginKey = 'kavita-lastlogin';
|
||||
public localeKey = 'kavita-locale';
|
||||
private currentUser: User | undefined;
|
||||
|
||||
// Stores values, when someone subscribes gives (1) of last values seen.
|
||||
|
@ -147,15 +148,11 @@ export class AccountService {
|
|||
this.currentUser = user;
|
||||
this.currentUserSource.next(user);
|
||||
|
||||
if (user) {
|
||||
this.messageHub.createHubConnection(user, this.hasAdminRole(user));
|
||||
}
|
||||
|
||||
this.hasValidLicense().subscribe();
|
||||
|
||||
this.stopRefreshTokenTimer();
|
||||
|
||||
if (this.currentUser !== undefined) {
|
||||
if (this.currentUser) {
|
||||
this.messageHub.createHubConnection(this.currentUser, this.hasAdminRole(this.currentUser));
|
||||
this.hasValidLicense().subscribe();
|
||||
this.startRefreshTokenTimer();
|
||||
}
|
||||
}
|
||||
|
@ -270,6 +267,9 @@ export class AccountService {
|
|||
if (this.currentUser !== undefined && this.currentUser !== null) {
|
||||
this.currentUser.preferences = settings;
|
||||
this.setCurrentUser(this.currentUser);
|
||||
|
||||
// Update the locale on disk (for logout only)
|
||||
localStorage.setItem(this.localeKey, this.currentUser.preferences.locale);
|
||||
}
|
||||
return settings;
|
||||
}), takeUntilDestroyed(this.destroyRef));
|
||||
|
|
|
@ -19,6 +19,7 @@ import { LibraryService } from './library.service';
|
|||
import { MemberService } from './member.service';
|
||||
import { ReaderService } from './reader.service';
|
||||
import { SeriesService } from './series.service';
|
||||
import {translate, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||
export type SeriesActionCallback = (series: Series) => void;
|
||||
|
@ -42,7 +43,8 @@ export class ActionService implements OnDestroy {
|
|||
|
||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { }
|
||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
|
||||
private translocoService: TranslocoService) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
|
@ -64,7 +66,7 @@ export class ActionService implements OnDestroy {
|
|||
const force = false; // await this.promptIfForce();
|
||||
|
||||
this.libraryService.scan(library.id, force).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Scan queued for ' + library.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: library.name}));
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
@ -83,7 +85,7 @@ export class ActionService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.confirm('Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
@ -93,7 +95,7 @@ export class ActionService implements OnDestroy {
|
|||
const forceUpdate = true; //await this.promptIfForce();
|
||||
|
||||
this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Scan queued for ' + library.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: library.name}));
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
@ -119,7 +121,7 @@ export class ActionService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.alert('This is a long running process. Please give it the time to complete before invoking again.')) {
|
||||
if (!await this.confirmService.alert(translate('toasts.alert-long-running'))) {
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
@ -127,7 +129,7 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
|
||||
this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Library file analysis queued for ' + library.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.library-file-analysis-queued', {name: library.name}));
|
||||
if (callback) {
|
||||
callback(library);
|
||||
}
|
||||
|
@ -142,7 +144,7 @@ export class ActionService implements OnDestroy {
|
|||
markSeriesAsRead(series: Series, callback?: SeriesActionCallback) {
|
||||
this.seriesService.markRead(series.id).pipe(take(1)).subscribe(res => {
|
||||
series.pagesRead = series.pages;
|
||||
this.toastr.success(series.name + ' is now read');
|
||||
this.toastr.success(this.translocoService.translate('toasts.entity-read', {name: series.name}));
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -157,7 +159,7 @@ export class ActionService implements OnDestroy {
|
|||
markSeriesAsUnread(series: Series, callback?: SeriesActionCallback) {
|
||||
this.seriesService.markUnread(series.id).pipe(take(1)).subscribe(res => {
|
||||
series.pagesRead = 0;
|
||||
this.toastr.success(series.name + ' is now unread');
|
||||
this.toastr.success(this.translocoService.translate('toasts.entity-unread', {name: series.name}));
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -171,7 +173,7 @@ export class ActionService implements OnDestroy {
|
|||
*/
|
||||
async scanSeries(series: Series, callback?: SeriesActionCallback) {
|
||||
this.seriesService.scan(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Scan queued for ' + series.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: series.name}));
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -185,7 +187,7 @@ export class ActionService implements OnDestroy {
|
|||
*/
|
||||
analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) {
|
||||
this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Scan queued for ' + series.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: series.name}));
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -198,7 +200,7 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
async refreshMetdata(series: Series, callback?: SeriesActionCallback) {
|
||||
if (!await this.confirmService.confirm('Refresh covers will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -206,7 +208,7 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
|
||||
this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => {
|
||||
this.toastr.info('Refresh covers queued for ' + series.name);
|
||||
this.toastr.info(this.translocoService.translate('toasts.refresh-covers-queued', {name: series.name}));
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
|
@ -223,7 +225,7 @@ export class ActionService implements OnDestroy {
|
|||
this.readerService.markVolumeRead(seriesId, volume.id).pipe(take(1)).subscribe(() => {
|
||||
volume.pagesRead = volume.pages;
|
||||
volume.chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
this.toastr.success('Marked as Read');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-read'));
|
||||
|
||||
if (callback) {
|
||||
callback(volume);
|
||||
|
@ -241,7 +243,7 @@ export class ActionService implements OnDestroy {
|
|||
this.readerService.markVolumeUnread(seriesId, volume.id).subscribe(() => {
|
||||
volume.pagesRead = 0;
|
||||
volume.chapters?.forEach(c => c.pagesRead = 0);
|
||||
this.toastr.success('Marked as Unread');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-unread'));
|
||||
if (callback) {
|
||||
callback(volume);
|
||||
}
|
||||
|
@ -257,7 +259,7 @@ export class ActionService implements OnDestroy {
|
|||
markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
|
||||
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
chapter.pagesRead = chapter.pages;
|
||||
this.toastr.success('Marked as Read');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-read'));
|
||||
if (callback) {
|
||||
callback(chapter);
|
||||
}
|
||||
|
@ -273,7 +275,7 @@ export class ActionService implements OnDestroy {
|
|||
markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
|
||||
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
|
||||
chapter.pagesRead = 0;
|
||||
this.toastr.success('Marked as Unread');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-unread'));
|
||||
if (callback) {
|
||||
callback(chapter);
|
||||
}
|
||||
|
@ -294,7 +296,7 @@ export class ActionService implements OnDestroy {
|
|||
volume.chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
});
|
||||
chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
this.toastr.success('Marked as Read');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-read'));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
|
@ -315,7 +317,7 @@ export class ActionService implements OnDestroy {
|
|||
volume.chapters?.forEach(c => c.pagesRead = 0);
|
||||
});
|
||||
chapters?.forEach(c => c.pagesRead = 0);
|
||||
this.toastr.success('Marked as Unread');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-unread'));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
|
@ -333,7 +335,7 @@ export class ActionService implements OnDestroy {
|
|||
series.forEach(s => {
|
||||
s.pagesRead = s.pages;
|
||||
});
|
||||
this.toastr.success('Marked as Read');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-read'));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
|
@ -351,7 +353,7 @@ export class ActionService implements OnDestroy {
|
|||
series.forEach(s => {
|
||||
s.pagesRead = s.pages;
|
||||
});
|
||||
this.toastr.success('Marked as Unread');
|
||||
this.toastr.success(this.translocoService.translate('toasts.mark-unread'));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
|
@ -394,7 +396,7 @@ export class ActionService implements OnDestroy {
|
|||
|
||||
removeMultipleSeriesFromWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
|
||||
this.memberService.removeSeriesToWantToRead(seriesIds).subscribe(() => {
|
||||
this.toastr.success('Series removed from Want to Read list');
|
||||
this.toastr.success(this.translocoService.translate('toasts.series-removed-want-to-read'));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
|
@ -538,14 +540,14 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
async deleteMultipleSeries(seriesIds: Array<Series>, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: seriesIds.length}))) {
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => {
|
||||
this.toastr.success('Series deleted');
|
||||
this.toastr.success(this.translocoService.translate('toasts.series-deleted'));
|
||||
|
||||
if (callback) {
|
||||
callback(true);
|
||||
|
@ -554,7 +556,7 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
|
||||
async deleteSeries(series: Series, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-series'))) {
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
|
@ -563,15 +565,15 @@ export class ActionService implements OnDestroy {
|
|||
|
||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||
if (callback) {
|
||||
this.toastr.success('Series deleted');
|
||||
this.toastr.success(this.translocoService.translate('toasts.series-deleted'));
|
||||
callback(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendToDevice(chapterIds: Array<number>, device: Device, callback?: VoidActionCallback) {
|
||||
this.deviceSerivce.sendTo(chapterIds, device.id).subscribe(() => {
|
||||
this.toastr.success('File emailed to ' + device.name);
|
||||
this.deviceService.sendTo(chapterIds, device.id).subscribe(() => {
|
||||
this.toastr.success(this.translocoService.translate('toasts.file-send-to', {name: device.name}));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
|
@ -579,8 +581,8 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
|
||||
sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) {
|
||||
this.deviceSerivce.sendSeriesTo(seriesId, device.id).subscribe(() => {
|
||||
this.toastr.success('File(s) emailed to ' + device.name);
|
||||
this.deviceService.sendSeriesTo(seriesId, device.id).subscribe(() => {
|
||||
this.toastr.success(this.translocoService.translate('toasts.file-send-to', {name: device.name}));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
|
|
18
UI/Web/src/app/_services/localization.service.ts
Normal file
18
UI/Web/src/app/_services/localization.service.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Language} from "../_models/metadata/language";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LocalizationService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getLocales() {
|
||||
return this.httpClient.get<Language[]>(this.baseUrl + 'locale');
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
|
||||
import { PublicationStatusPipe } from '../pipe/publication-status.pipe';
|
||||
|
@ -13,6 +13,7 @@ import { StatCount } from '../statistics/_models/stat-count';
|
|||
import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
export enum DayOfWeek
|
||||
{
|
||||
|
@ -25,15 +26,15 @@ export enum DayOfWeek
|
|||
Saturday = 6,
|
||||
}
|
||||
|
||||
const publicationStatusPipe = new PublicationStatusPipe();
|
||||
const mangaFormatPipe = new MangaFormatPipe();
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StatisticsService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
translocoService = inject(TranslocoService);
|
||||
publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
|
||||
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
|
@ -41,7 +42,7 @@ export class StatisticsService {
|
|||
// TODO: Convert to httpParams object
|
||||
let url = 'stats/user/' + userId + '/read';
|
||||
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
|
||||
|
||||
|
||||
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url);
|
||||
}
|
||||
|
||||
|
@ -88,14 +89,14 @@ export class StatisticsService {
|
|||
getPublicationStatus() {
|
||||
return this.httpClient.get<StatCount<PublicationStatus>[]>(this.baseUrl + 'stats/server/count/publication-status').pipe(
|
||||
map(spreads => spreads.map(spread => {
|
||||
return {name: publicationStatusPipe.transform(spread.value), value: spread.count};
|
||||
return {name: this.publicationStatusPipe.transform(spread.value), value: spread.count};
|
||||
})));
|
||||
}
|
||||
|
||||
getMangaFormat() {
|
||||
return this.httpClient.get<StatCount<MangaFormat>[]>(this.baseUrl + 'stats/server/count/manga-format').pipe(
|
||||
map(spreads => spreads.map(spread => {
|
||||
return {name: mangaFormatPipe.transform(spread.value), value: spread.count};
|
||||
return {name: this.mangaFormatPipe.transform(spread.value), value: spread.count};
|
||||
})));
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme';
|
|||
import { TextResonse } from '../_types/text-response';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {translate} from "@ngneat/transloco";
|
||||
|
||||
|
||||
@Injectable({
|
||||
|
@ -98,7 +99,7 @@ export class ThemeService {
|
|||
this.currentTheme$.pipe(take(1)).subscribe(theme => {
|
||||
if (themes.filter(t => t.id === theme.id).length === 0) {
|
||||
this.setTheme(this.defaultTheme);
|
||||
this.toastr.info('The active theme no longer exists. Please refresh the page.');
|
||||
this.toastr.info(translate('toasts.theme-missing'));
|
||||
}
|
||||
});
|
||||
return themes;
|
||||
|
@ -151,7 +152,7 @@ export class ThemeService {
|
|||
// We need to load the styles into the browser
|
||||
this.fetchThemeContent(theme.id).subscribe(async (content) => {
|
||||
if (content === null) {
|
||||
await this.confirmService.alert('There is invalid or unsafe css in the theme. Please reach out to your admin to have this corrected. Defaulting to dark theme.');
|
||||
await this.confirmService.alert(translate('toasts.alert-bad-theme'));
|
||||
this.setTheme('dark');
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
<div>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">KavitaPlus Features</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5>Current Features</h5>
|
||||
<ul class="list-group mb-2">
|
||||
<li class="list-group-item">Scrobble Support</li>
|
||||
<li class="list-group-item">Series Recommendations</li>
|
||||
<li class="list-group-item">Series Reviews</li>
|
||||
<li class="list-group-item">Remove Donation on Side nav</li>
|
||||
</ul>
|
||||
|
||||
<h5>Planned Features</h5>
|
||||
<ul class="list-group mb-2">
|
||||
<li class="list-group-item">More external data providers</li>
|
||||
<li class="list-group-item">Webhooks</li>
|
||||
<li class="list-group-item">Kobo Progress Syncing</li>
|
||||
<li class="list-group-item">Trending/External rating integration</li>
|
||||
<li class="list-group-item">Your ideas upvoted via FeatHub</li>
|
||||
</ul>
|
||||
|
||||
<div class="text-muted">These feature unlock for the whole server while subscription active</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-feature-list-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './feature-list-modal.component.html',
|
||||
styleUrls: ['./feature-list-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FeatureListModalComponent {
|
||||
|
||||
constructor(private modal: NgbActiveModal) {}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
}
|
|
@ -1,18 +1,19 @@
|
|||
<div>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{review.username + "'s"}} Review {{review.isExternal ? '(external)' : ''}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
<ng-container *transloco="let t; read:'review-card-modal'">
|
||||
<div>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('user-review', {username: review.username})}} {{review.isExternal ? t('external-mod') : ''}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
|
||||
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
|
||||
{{t('go-to-review')}}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
|
||||
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
|
||||
Go To Review
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
|
|
|
@ -13,11 +13,12 @@ import {ReactiveFormsModule} from "@angular/forms";
|
|||
import {UserReview} from "../review-card/user-review";
|
||||
import {SpoilerComponent} from "../spoiler/spoiler.component";
|
||||
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-card-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe],
|
||||
imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe, TranslocoModule],
|
||||
templateUrl: './review-card-modal.component.html',
|
||||
styleUrls: ['./review-card-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
|
|
@ -1,35 +1,37 @@
|
|||
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
|
||||
<div *ngIf="isMyReview" class="my-review">
|
||||
<i class="fa-solid fa-star" aria-hidden="true" title="This is your review"></i>
|
||||
<span class="visually-hidden">This is your review</span>
|
||||
<ng-container *transloco="let t; read:'review-card'">
|
||||
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
|
||||
<div *ngIf="isMyReview" class="my-review">
|
||||
<i class="fa-solid fa-star" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
<span class="visually-hidden">{{t('your-review')}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" [title]="review.tagline">
|
||||
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
|
||||
<ng-template #noTagline>
|
||||
{{review.isExternal ? 'External Review' : 'Review'}}
|
||||
</ng-template>
|
||||
</h6>
|
||||
<p class="card-text no-images">
|
||||
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
|
||||
</p>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" [title]="review.tagline">
|
||||
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
|
||||
<ng-template #noTagline>
|
||||
{{review.isExternal ? t('external-review') : t('local-review')}}
|
||||
</ng-template>
|
||||
</h6>
|
||||
<p class="card-text no-images">
|
||||
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent text-muted">
|
||||
<ng-container *ngIf="isMyReview; else normalReview">
|
||||
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" title="This is your review"></i>
|
||||
</ng-container>
|
||||
<ng-template #normalReview>
|
||||
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||
</ng-template>
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
<span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span>
|
||||
<div class="card-footer bg-transparent text-muted">
|
||||
<ng-container *ngIf="isMyReview; else normalReview">
|
||||
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
</ng-container>
|
||||
<ng-template #normalReview>
|
||||
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||
</ng-template>
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -9,11 +9,12 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
|||
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {ProviderImagePipe} from "../../pipe/provider-image.pipe";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe],
|
||||
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoModule],
|
||||
templateUrl: './review-card.component.html',
|
||||
styleUrls: ['./review-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,28 +1,30 @@
|
|||
<div>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit Review</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
<ng-container *transloco="let t; read:'review-series-modal'">
|
||||
<div>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form [formGroup]="reviewGroup">
|
||||
<div class="row g-0">
|
||||
<label for="tagline" class="form-label">Tagline</label>
|
||||
<input id="tagline" class="form-control" formControlName="tagline" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form [formGroup]="reviewGroup">
|
||||
<div class="row g-0">
|
||||
<label for="tagline" class="form-label">{{t('tagline-label')}}</label>
|
||||
<input id="tagline" class="form-control" formControlName="tagline" />
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-2">
|
||||
<label for="review" class="form-label">Review</label>
|
||||
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row g-0 mt-2">
|
||||
<label for="review" class="form-label">{{t('review-label')}}</label>
|
||||
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" (click)="close()">Close</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
|
|
@ -4,11 +4,12 @@ import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap';
|
|||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import {UserReview} from "../review-card/user-review";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-series-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgbRating, ReactiveFormsModule],
|
||||
imports: [CommonModule, NgbRating, ReactiveFormsModule, TranslocoModule],
|
||||
templateUrl: './review-series-modal.component.html',
|
||||
styleUrls: ['./review-series-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event";
|
||||
import {TranslocoPipe, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'scrobbleEventType',
|
||||
|
@ -7,13 +8,20 @@ import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event";
|
|||
})
|
||||
export class ScrobbleEventTypePipe implements PipeTransform {
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: ScrobbleEventType): string {
|
||||
switch (value) {
|
||||
case ScrobbleEventType.ChapterRead: return 'Reading Progress';
|
||||
case ScrobbleEventType.ScoreUpdated: return 'Rating Update';
|
||||
case ScrobbleEventType.AddWantToRead: return 'Want To Read: Add';
|
||||
case ScrobbleEventType.RemoveWantToRead: return 'Want To Read: Remove';
|
||||
case ScrobbleEventType.Review: return 'Review update';
|
||||
case ScrobbleEventType.ChapterRead:
|
||||
return this.translocoService.translate('scrobble-event-type-pipe.chapter-read');
|
||||
case ScrobbleEventType.ScoreUpdated:
|
||||
return this.translocoService.translate('scrobble-event-type-pipe.score-updated');
|
||||
case ScrobbleEventType.AddWantToRead:
|
||||
return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-add');
|
||||
case ScrobbleEventType.RemoveWantToRead:
|
||||
return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-remove');
|
||||
case ScrobbleEventType.Review:
|
||||
return this.translocoService.translate('scrobble-event-type-pipe.review');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
|
||||
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
|
||||
<span *ngIf="isCollapsed; else show">Spoiler, click to show</span>
|
||||
<ng-template #show>
|
||||
<div [innerHTML]="html | safeHtml"></div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<ng-container *transloco="let t; read:'spoiler'">
|
||||
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
|
||||
<span *ngIf="isCollapsed; else show">{{t('click-to-show')}}</span>
|
||||
<ng-template #show>
|
||||
<div [innerHTML]="html | safeHtml"></div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -9,11 +9,12 @@ import {
|
|||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-spoiler',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SafeHtmlPipe],
|
||||
imports: [CommonModule, SafeHtmlPipe, TranslocoModule],
|
||||
templateUrl: './spoiler.component.html',
|
||||
styleUrls: ['./spoiler.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
|
|
@ -1,51 +1,50 @@
|
|||
<h5>Scrobble History</h5>
|
||||
<p>Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active
|
||||
scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it
|
||||
is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.</p>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-10">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="form-group pe-1">
|
||||
<label for="filter">Filter</label>
|
||||
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
|
||||
</div>
|
||||
</form>
|
||||
<ng-container *transloco="let t; read:'user-scrobble-history'">
|
||||
<h5>{{t('title')}}</h5>
|
||||
<p>{{t('description')}}</p>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-10">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="form-group pe-1">
|
||||
<label for="filter">{{t('filter-label')}}</label>
|
||||
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 mt-4">
|
||||
<ngb-pagination *ngIf="pagination"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
[collectionSize]="pagination.totalItems"
|
||||
(pageChange)="onPageChange($event)"
|
||||
></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mt-4">
|
||||
<ngb-pagination *ngIf="pagination"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
[collectionSize]="pagination.totalItems"
|
||||
(pageChange)="onPageChange($event)"
|
||||
></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-striped table-hover table-sm scrollable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="created" (sort)="updateSort($event)">
|
||||
Created
|
||||
{{t('created-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="lastModified" (sort)="updateSort($event)" direction="desc">
|
||||
Last Modified
|
||||
{{t('last-modified-header')}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
Type
|
||||
{{t('type-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="seriesName" (sort)="updateSort($event)">
|
||||
Series
|
||||
{{t('series-header')}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
Data
|
||||
{{t('data-header')}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
Is Processed
|
||||
{{t('is-processed-header')}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngIf="events.length === 0">
|
||||
<td colspan="6">No Data</td>
|
||||
<td colspan="6">{{t('no-data')}}/td>
|
||||
</tr>
|
||||
<tr *ngFor="let item of events; let idx = index;">
|
||||
<td>
|
||||
|
@ -63,22 +62,24 @@
|
|||
<td>
|
||||
<ng-container [ngSwitch]="item.scrobbleEventType">
|
||||
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
|
||||
Volume {{item.volumeNumber}} Chapter {{item.chapterNumber}}
|
||||
{{t('volume-and-chapter-num', {v: item.volumeNumber, c: item.chapterNumber})}}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
|
||||
Rating {{item.rating}}
|
||||
{{t('rating', {r: item.rating})}}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
Not Applicable
|
||||
{{t('not-applicable')}}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</td>
|
||||
<td>
|
||||
<i class="fa-regular fa-circle icon" aria-hidden="true" *ngIf="!item.isProcessed"></i>
|
||||
<i class="fa-solid fa-check-circle icon" aria-hidden="true" *ngIf="item.isProcessed"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">{{item.isProcessed ? 'Processed' : 'Not Processed'}}</span>
|
||||
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
|
||||
{{item.isProcessed ? t('processed') : t('not-processed')}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -2,21 +2,21 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec
|
|||
import {CommonModule} from '@angular/common';
|
||||
|
||||
import {ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {shareReplay} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
|
||||
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
|
||||
import {NgbPagination} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
|
||||
import {debounceTime, map, take, tap} from "rxjs/operators";
|
||||
import {debounceTime, take} from "rxjs/operators";
|
||||
import {PaginatedResult, Pagination} from "../../_models/pagination";
|
||||
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-scrobble-history',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader],
|
||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule],
|
||||
templateUrl: './user-scrobble-history.component.html',
|
||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,63 +1,66 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Choose a Directory</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ng-container *transloco="let t; read:'directory-picker'">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="typeahead-focus" class="form-label">Path</label>
|
||||
<div class="input-group">
|
||||
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
|
||||
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
|
||||
(ngModelChange)="updateTable()" #instance="ngbTypeahead" placeholder="Start typing or select path"
|
||||
[resultTemplate]="rt" />
|
||||
</div>
|
||||
<ng-template #rt let-r="result" let-t="term">
|
||||
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
|
||||
</ng-template>
|
||||
<label for="typeahead-focus" class="form-label">{{t('path')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
|
||||
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
|
||||
(ngModelChange)="updateTable()" #instance="ngbTypeahead" [placeholder]="t('path-placeholder')"
|
||||
[resultTemplate]="rt" />
|
||||
</div>
|
||||
<ng-template #rt let-r="result" let-t="term">
|
||||
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
|
||||
</ng-template>
|
||||
</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; let last = last;">
|
||||
<ng-container *ngIf="last; else nonActive">
|
||||
{{route}}
|
||||
</ng-container>
|
||||
<ng-template #nonActive>
|
||||
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ol>
|
||||
<ng-template #noBreadcrumb>
|
||||
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory? Try checking / first.
|
||||
</div>
|
||||
</ng-template>
|
||||
<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; let last = last;">
|
||||
<ng-container *ngIf="last; else nonActive">
|
||||
{{route}}
|
||||
</ng-container>
|
||||
<ng-template #nonActive>
|
||||
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ol>
|
||||
<ng-template #noBreadcrumb>
|
||||
<div class="breadcrumb">{{t('instructions')}}
|
||||
</div>
|
||||
</ng-template>
|
||||
</nav>
|
||||
|
||||
<table class="table table-striped scrollable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">Type</th>
|
||||
<th scope="col">Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr (click)="goBack()">
|
||||
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
|
||||
<td>...</td>
|
||||
</tr>
|
||||
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)" style="cursor: pointer;" [ngClass]="{'disabled': folder.disabled}">
|
||||
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
|
||||
<td id="folder--{{idx}}">
|
||||
{{folder.name}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">{{t('type-header')}}</th>
|
||||
<th scope="col">{{t('name-header')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr (click)="goBack()">
|
||||
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
|
||||
<td>...</td>
|
||||
</tr>
|
||||
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)" style="cursor: pointer;" [ngClass]="{'disabled': folder.disabled}">
|
||||
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
|
||||
<td id="folder--{{idx}}">
|
||||
{{folder.name}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank" rel="noopener noreferrer">Help</a>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="share()">Share</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank" rel="noopener noreferrer">{{t('help')}}</a>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
|
||||
<button type="button" class="btn btn-primary" (click)="share()">{{t('share')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { DirectoryDto } from 'src/app/_models/system/directory-dto';
|
|||
import { LibraryService } from '../../../_services/library.service';
|
||||
import { NgIf, NgFor, NgClass } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
|
||||
export interface DirectoryPickerResult {
|
||||
|
@ -20,13 +21,13 @@ export interface DirectoryPickerResult {
|
|||
templateUrl: './directory-picker.component.html',
|
||||
styleUrls: ['./directory-picker.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass]
|
||||
imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass, TranslocoModule]
|
||||
})
|
||||
export class DirectoryPickerComponent implements OnInit {
|
||||
|
||||
@Input() startingFolder: string = '';
|
||||
/**
|
||||
* Url to give more information about selecting directories. Passing nothing will suppress.
|
||||
* Url to give more information about selecting directories. Passing nothing will suppress.
|
||||
*/
|
||||
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita';
|
||||
|
||||
|
@ -161,7 +162,7 @@ export class DirectoryPickerComponent implements OnInit {
|
|||
while(this.routeStack.items.length - 1 > index) {
|
||||
this.routeStack.pop();
|
||||
}
|
||||
|
||||
|
||||
const fullPath = this.routeStack.items.join('/');
|
||||
this.path = fullPath;
|
||||
this.loadChildren(fullPath);
|
||||
|
|
|
@ -1,33 +1,36 @@
|
|||
<ng-container *transloco="let t; read:'library-access-modal'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Library Access</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="list-group">
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
There are no libraries setup yet.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-check">
|
||||
<input id="select-all" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="reset()">{{t('reset')}}</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -6,13 +6,14 @@ import {LibraryService} from 'src/app/_services/library.service';
|
|||
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
|
||||
import {NgFor, NgIf} from '@angular/common';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-access-modal',
|
||||
templateUrl: './library-access-modal.component.html',
|
||||
styleUrls: ['./library-access-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf],
|
||||
imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LibraryAccessModalComponent implements OnInit {
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
<form [formGroup]="resetPasswordForm">
|
||||
<ng-container *transloco="let t; read:'reset-password-modal'">
|
||||
<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="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title', {username: member.username | sentenceCase})}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" *ngIf="errorMessage !== ''">
|
||||
<strong>Error: </strong> {{errorMessage}}
|
||||
</div>
|
||||
<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 class="alert alert-info" *ngIf="errorMessage !== ''">
|
||||
<strong>{{t('error-label')}}</strong> {{errorMessage}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">{{t('new-password-label')}}</label>
|
||||
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="resetPasswordForm.value.password.length === 0" (click)="save()">Save</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="resetPasswordForm.value.password.length === 0" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</ng-container>
|
||||
|
|
|
@ -5,13 +5,14 @@ import { Member } from 'src/app/_models/auth/member';
|
|||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reset-password-modal',
|
||||
templateUrl: './reset-password-modal.component.html',
|
||||
styleUrls: ['./reset-password-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgIf, SentenceCasePipe]
|
||||
imports: [ReactiveFormsModule, NgIf, SentenceCasePipe, TranslocoModule]
|
||||
})
|
||||
export class ResetPasswordModalComponent {
|
||||
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<app-side-nav-companion-bar>
|
||||
<h2 title>
|
||||
Admin Dashboard
|
||||
</h2>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid g-0">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
|
||||
<ng-container *transloco="let t; read: 'admin-dashboard'">
|
||||
<app-side-nav-companion-bar>
|
||||
<h2 title>
|
||||
{{t('title')}}
|
||||
</h2>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid g-0">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class=tab>
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ t(tab.title) }}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.fragment === TabID.General">
|
||||
<app-manage-settings></app-manage-settings>
|
||||
|
@ -18,10 +19,10 @@
|
|||
<app-manage-media-settings></app-manage-media-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Users">
|
||||
<app-manage-users></app-manage-users>
|
||||
<app-manage-users></app-manage-users>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Libraries">
|
||||
<app-manage-library></app-manage-library>
|
||||
<app-manage-library></app-manage-library>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Logs">
|
||||
<app-manage-logs></app-manage-logs>
|
||||
|
@ -36,12 +37,14 @@
|
|||
<app-manage-tasks-settings></app-manage-tasks-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
|
||||
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
|
||||
<p>{{t('kavita+-desc-part-1')}} <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
|
||||
<app-license></app-license>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
|
||||
import { LicenseComponent } from '../license/license.component';
|
||||
import { ManageTasksSettingsComponent } from '../manage-tasks-settings/manage-tasks-settings.component';
|
||||
import { ServerStatsComponent } from '../../statistics/_components/server-stats/server-stats.component';
|
||||
import { ManageSystemComponent } from '../manage-system/manage-system.component';
|
||||
import { ManageLogsComponent } from '../manage-logs/manage-logs.component';
|
||||
import { ManageLibraryComponent } from '../manage-library/manage-library.component';
|
||||
import { ManageUsersComponent } from '../manage-users/manage-users.component';
|
||||
import { ManageMediaSettingsComponent } from '../manage-media-settings/manage-media-settings.component';
|
||||
import { ManageEmailSettingsComponent } from '../manage-email-settings/manage-email-settings.component';
|
||||
import { ManageSettingsComponent } from '../manage-settings/manage-settings.component';
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {ActivatedRoute, RouterLink} from '@angular/router';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {NavService} from '../../_services/nav.service';
|
||||
import {SentenceCasePipe} from '../../pipe/sentence-case.pipe';
|
||||
import {LicenseComponent} from '../license/license.component';
|
||||
import {ManageTasksSettingsComponent} from '../manage-tasks-settings/manage-tasks-settings.component';
|
||||
import {ServerStatsComponent} from '../../statistics/_components/server-stats/server-stats.component';
|
||||
import {ManageSystemComponent} from '../manage-system/manage-system.component';
|
||||
import {ManageLogsComponent} from '../manage-logs/manage-logs.component';
|
||||
import {ManageLibraryComponent} from '../manage-library/manage-library.component';
|
||||
import {ManageUsersComponent} from '../manage-users/manage-users.component';
|
||||
import {ManageMediaSettingsComponent} from '../manage-media-settings/manage-media-settings.component';
|
||||
import {ManageEmailSettingsComponent} from '../manage-email-settings/manage-email-settings.component';
|
||||
import {ManageSettingsComponent} from '../manage-settings/manage-settings.component';
|
||||
import {NgFor, NgIf} from '@angular/common';
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
enum TabID {
|
||||
General = '',
|
||||
|
@ -26,7 +29,6 @@ enum TabID {
|
|||
Users = 'users',
|
||||
Libraries = 'libraries',
|
||||
System = 'system',
|
||||
Plugins = 'plugins',
|
||||
Tasks = 'tasks',
|
||||
Logs = 'logs',
|
||||
Statistics = 'statistics',
|
||||
|
@ -38,24 +40,28 @@ enum TabID {
|
|||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, ManageUsersComponent, ManageLibraryComponent, ManageLogsComponent, ManageSystemComponent, ServerStatsComponent, ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe]
|
||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, ManageUsersComponent, ManageLibraryComponent, ManageLogsComponent, ManageSystemComponent, ServerStatsComponent, ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe, TranslocoModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
tabs: Array<{title: string, fragment: string}> = [
|
||||
{title: 'General', fragment: TabID.General},
|
||||
{title: 'Users', fragment: TabID.Users},
|
||||
{title: 'Libraries', fragment: TabID.Libraries},
|
||||
//{title: 'Logs', fragment: TabID.Logs},
|
||||
{title: 'Media', fragment: TabID.Media},
|
||||
{title: 'Email', fragment: TabID.Email},
|
||||
{title: 'Tasks', fragment: TabID.Tasks},
|
||||
{title: 'Statistics', fragment: TabID.Statistics},
|
||||
{title: 'System', fragment: TabID.System},
|
||||
{title: 'Kavita+', fragment: TabID.KavitaPlus},
|
||||
{title: 'general-tab', fragment: TabID.General},
|
||||
{title: 'users-tab', fragment: TabID.Users},
|
||||
{title: 'libraries-tab', fragment: TabID.Libraries},
|
||||
//{title: 'logs-tab', fragment: TabID.Logs},
|
||||
{title: 'media-tab', fragment: TabID.Media},
|
||||
{title: 'email-tab', fragment: TabID.Email},
|
||||
{title: 'tasks-tab', fragment: TabID.Tasks},
|
||||
{title: 'statistics-tab', fragment: TabID.Statistics},
|
||||
{title: 'system-tab', fragment: TabID.System},
|
||||
{title: 'kavita+-tab', fragment: TabID.KavitaPlus},
|
||||
];
|
||||
active = this.tabs[0];
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
get TabID() {
|
||||
return TabID;
|
||||
}
|
||||
|
@ -69,11 +75,12 @@ export class DashboardComponent implements OnInit {
|
|||
} else {
|
||||
this.active = this.tabs[0]; // Default to first tab
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.titleService.setTitle('Kavita - Admin Dashboard');
|
||||
this.titleService.setTitle('Kavita - ' + this.translocoService.translate('admin-dashboard.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,68 +1,70 @@
|
|||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
<ng-container *transloco="let t; read: 'edit-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<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"
|
||||
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
|
||||
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
<form [formGroup]="userForm">
|
||||
<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">{{t('username')}}</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text"
|
||||
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
|
||||
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
{{t('required')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
|
||||
<div id="email-validations" class="invalid-feedback"
|
||||
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
{{t('required')}}
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
{{t('not-valid-email')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
|
||||
<div id="email-validations" class="invalid-feedback"
|
||||
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSaving ? t('saving') : t('update')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -10,13 +10,14 @@ import { RestrictionSelectorComponent } from '../../user-settings/restriction-se
|
|||
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
|
||||
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-user',
|
||||
templateUrl: './edit-user.component.html',
|
||||
styleUrls: ['./edit-user.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe]
|
||||
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoModule]
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
|
||||
|
|
|
@ -1,61 +1,61 @@
|
|||
<div class="modal-container">
|
||||
<ng-container *transloco="let t; read: 'invite-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p>
|
||||
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank">host your own</a>
|
||||
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the account manually.
|
||||
<p>
|
||||
{{t('description')}}
|
||||
</p>
|
||||
|
||||
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="emailLink !== ''">
|
||||
<h4>{{t('setup-user-title')}}</h4>
|
||||
<p>{{t('setup-user-description')}}
|
||||
</p>
|
||||
|
||||
<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" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
|
||||
</div>
|
||||
</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 externally accessible, 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" rel="noopener noreferrer">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>
|
||||
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
|
||||
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('setup-user-account-tooltip')" [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 || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<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 ? t('inviting') : t('invite')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -12,13 +12,14 @@ import { RestrictionSelectorComponent } from '../../user-settings/restriction-se
|
|||
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
|
||||
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite-user',
|
||||
templateUrl: './invite-user.component.html',
|
||||
styleUrls: ['./invite-user.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent]
|
||||
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent, TranslocoModule]
|
||||
})
|
||||
export class InviteUserComponent implements OnInit {
|
||||
|
||||
|
@ -61,7 +62,7 @@ export class InviteUserComponent implements OnInit {
|
|||
this.emailLink = data.emailLink;
|
||||
this.isSending = false;
|
||||
if (data.emailSent) {
|
||||
this.toastr.info('Email sent to ' + email);
|
||||
this.toastr.info(translate('toasts.email-sent', {email: email}));
|
||||
this.modal.close(true);
|
||||
}
|
||||
}, err => {
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
<h4>Libraries</h4>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<ng-container *transloco="let t; read: 'library-selector'">
|
||||
<h4>{{t('title')}}</h4>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<div class="form-check" *ngIf="allLibraries.length > 0">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
<input id="select-all" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
There are no libraries setup yet.
|
||||
</li>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -5,13 +5,14 @@ import { Member } from 'src/app/_models/auth/member';
|
|||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-selector',
|
||||
templateUrl: './library-selector.component.html',
|
||||
styleUrls: ['./library-selector.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor]
|
||||
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor, TranslocoModule]
|
||||
})
|
||||
export class LibrarySelectorComponent implements OnInit {
|
||||
|
||||
|
@ -41,7 +42,7 @@ export class LibrarySelectorComponent implements OnInit {
|
|||
setupSelections() {
|
||||
this.selections = new SelectionModel<Library>(false, this.allLibraries);
|
||||
this.isLoading = false;
|
||||
|
||||
|
||||
// If a member is passed in, then auto-select their libraries
|
||||
if (this.member !== undefined) {
|
||||
this.member.libraries.forEach(lib => {
|
||||
|
|
|
@ -1,85 +1,88 @@
|
|||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-10">
|
||||
<h4 id="license-key-header">Kavita+ License</h4>
|
||||
</div>
|
||||
<div class="col-2 text-end">
|
||||
<ng-container *ngIf="hasLicense; else noLicense">
|
||||
<ng-container *ngIf="hasValidLicense; else invalidLicenseBuy">
|
||||
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">Manage</a>
|
||||
<ng-container *transloco="let t; read: 'license'">
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-10">
|
||||
<h4 id="license-key-header">{{t('title')}}</h4>
|
||||
</div>
|
||||
<div class="col-2 text-end">
|
||||
<ng-container *ngIf="hasLicense; else noLicense">
|
||||
<ng-container *ngIf="hasValidLicense; else invalidLicenseBuy">
|
||||
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
</ng-container>
|
||||
<ng-template #invalidLicenseBuy>
|
||||
<a class="btn btn-primary btn-sm me-1"
|
||||
[ngbTooltip]="t('invalid-license-tooltip')"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>{{t('renew')}}</a>
|
||||
</ng-template>
|
||||
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
|
||||
<span *ngIf="!isChecking">{{t('check')}}</span>
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
|
||||
<span *ngIf="!isViewMode">{{t('cancel')}}</span>
|
||||
<span *ngIf="isViewMode">{{t('edit')}}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-template #invalidLicenseBuy>
|
||||
<a class="btn btn-primary btn-sm me-1"
|
||||
ngbTooltip="If your subscription has ended, you must email support to get a new subscription created"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>Renew</a>
|
||||
<ng-template #noLicense>
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
|
||||
</ng-template>
|
||||
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
|
||||
<span *ngIf="!isChecking">Check</span>
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
|
||||
<span *ngIf="!isViewMode">Cancel</span>
|
||||
<span *ngIf="isViewMode">Edit</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-template #noLicense>
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">Buy</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Activate' : 'Cancel'}}</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="isViewMode">
|
||||
<div class="container-fluid row">
|
||||
<ng-container *ngIf="isViewMode">
|
||||
<div class="container-fluid row">
|
||||
<span class="col-12">
|
||||
<ng-container *ngIf="hasLicense; else noToken">
|
||||
<span class="me-1">*********</span>
|
||||
<ng-container *ngIf="!isChecking; else checking">
|
||||
<i *ngIf="hasValidLicense" ngbTooltip="License is valid" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">License is Valid</span>
|
||||
<i *ngIf="hasValidLicense" [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">{{t('license-valid')}}</span>
|
||||
</i>
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" ngbTooltip="License Invalid" *ngIf="!hasValidLicense">
|
||||
<span class="visually-hidden">License Not Valid</span>
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')" *ngIf="!hasValidLicense">
|
||||
<span class="visually-hidden">{{t('license-not-valid')}}</span>
|
||||
</i>
|
||||
</ng-container>
|
||||
<ng-template #checking>
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
<ng-template #noToken>No license key</ng-template>
|
||||
<ng-template #noToken>{{t('no-license-key')}}</ng-template>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||
<form [formGroup]="formGroup">
|
||||
<p>Enter the License Key and Email used to register with Stripe</p>
|
||||
<div class="form-group mb-3">
|
||||
<label for="license-key">License Key</label>
|
||||
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||
<form [formGroup]="formGroup">
|
||||
<p>{{t('activate-description')}}</p>
|
||||
<div class="form-group mb-3">
|
||||
<label for="license-key">{{t('activate-license-label')}}</label>
|
||||
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="email">{{t('activate-email-label')}}</label>
|
||||
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
|
||||
</div>
|
||||
</form>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header" (click)="deleteLicense()">
|
||||
{{t('activate-delete')}}
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
<span *ngIf="!isSaving">{{t('activate-save')}}</span>
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
|
||||
</div>
|
||||
</form>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header" (click)="deleteLicense()">
|
||||
Delete
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
<span *ngIf="!isSaving">Save</span>
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -15,6 +15,7 @@ import { LoadingComponent } from '../../shared/loading/loading.component';
|
|||
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-license',
|
||||
|
@ -22,7 +23,7 @@ import {environment} from "../../../environments/environment";
|
|||
styleUrls: ['./license.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule]
|
||||
imports: [NgIf, NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule, TranslocoModule]
|
||||
})
|
||||
export class LicenseComponent implements OnInit {
|
||||
|
||||
|
@ -71,9 +72,9 @@ export class LicenseComponent implements OnInit {
|
|||
this.accountService.hasValidLicense(true).subscribe(isValid => {
|
||||
this.hasValidLicense = isValid;
|
||||
if (!this.hasValidLicense) {
|
||||
this.toastr.info("License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.");
|
||||
this.toastr.info(translate('toasts.k+-license-saved'));
|
||||
} else {
|
||||
this.toastr.success('Kavita+ unlocked!');
|
||||
this.toastr.success(translate('toasts.k+-unlocked'));
|
||||
}
|
||||
this.hasLicense = this.formGroup.get('licenseKey')!.value.length > 0;
|
||||
this.resetForm();
|
||||
|
@ -85,7 +86,7 @@ export class LicenseComponent implements OnInit {
|
|||
if (err.hasOwnProperty('error')) {
|
||||
this.toastr.error(JSON.parse(err['error'])['message']);
|
||||
} else {
|
||||
this.toastr.error("There was an error when activating your license. Please try again.");
|
||||
this.toastr.error(translate('toasts.k+-error'));
|
||||
}
|
||||
this.isSaving = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -93,7 +94,7 @@ export class LicenseComponent implements OnInit {
|
|||
}
|
||||
|
||||
async deleteLicense() {
|
||||
if (!await this.confirmService.confirm('This will only delete Kavita\'s license key and allow a buy link to show. This will not cancel your subscription! Use this only if directed by support!')) {
|
||||
if (!await this.confirmService.confirm(translate('k+-delete-key'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,53 +1,54 @@
|
|||
<p>This table contains issues found during scan or reading of your media. This list is non-managed.
|
||||
You can clear it at any time and use Library (Force) Scan to perform analysis. A list of some common errors and what
|
||||
they mean can be found on the <a rel="noopener noreferrer" target="_blank" href="https://wiki.kavitareader.com/en/guides/managing-your-files/scanner#media-errors">wiki</a>.</p>
|
||||
<ng-container *transloco="let t; read: 'manage-alerts'">
|
||||
<p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" href="https://wiki.kavitareader.com/en/guides/managing-your-files/scanner#media-errors">{{t('description-part-2')}}</a></p>
|
||||
|
||||
<form [formGroup]="formGroup">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" placeholder="Filter" formControlName="filter" />
|
||||
<button class="btn btn-primary" type="button" (click)="clear()">Clear Alerts</button>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
||||
<button class="btn btn-primary" type="button" (click)="clear()">{{t('clear-alerts')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="table table-striped table-hover table-sm table-hover">
|
||||
</form>
|
||||
<table class="table table-striped table-hover table-sm table-hover">
|
||||
<thead #header>
|
||||
<tr>
|
||||
<th scope="col"sortable="extension" (sort)="onSort($event)">
|
||||
Extension
|
||||
</th>
|
||||
<th scope="col" sortable="filePath" (sort)="onSort($event)">
|
||||
File
|
||||
</th>
|
||||
<th scope="col" sortable="comment" (sort)="onSort($event)">
|
||||
Comment
|
||||
</th>
|
||||
<th scope="col" sortable="details" (sort)="onSort($event)">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="col" sortable="extension" (sort)="onSort($event)">
|
||||
{{t('extension-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="filePath" (sort)="onSort($event)">
|
||||
{{t('file-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="comment" (sort)="onSort($event)">
|
||||
{{t('comment-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="details" (sort)="onSort($event)">
|
||||
{{t('details-header')}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody #container>
|
||||
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
|
||||
<ng-container *ngIf="data | filter: filterList as filteredData">
|
||||
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
|
||||
<tr *ngFor="let item of filteredData; index as i">
|
||||
<td>
|
||||
{{item.extension}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.filePath}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.details}}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
|
||||
<ng-container *ngIf="data | filter: filterList as filteredData">
|
||||
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
|
||||
<tr *ngFor="let item of filteredData; index as i">
|
||||
<td>
|
||||
{{item.extension}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.filePath}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.details}}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -20,6 +20,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|||
import { FilterPipe } from '../../pipe/filter.pipe';
|
||||
import { LoadingComponent } from '../../shared/loading/loading.component';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-alerts',
|
||||
|
@ -27,7 +28,7 @@ import { NgIf, NgFor } from '@angular/common';
|
|||
styleUrls: ['./manage-alerts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader]
|
||||
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader, TranslocoModule]
|
||||
})
|
||||
export class ManageAlertsComponent implements OnInit {
|
||||
|
||||
|
|
|
@ -1,45 +1,46 @@
|
|||
<div class="container-fluid">
|
||||
<ng-container *transloco="let t; read: 'manage-email-settings'">
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<h4>Email Services (SMTP)</h4>
|
||||
<p>Kavita comes out of the box with an email service to power tasks like inviting users, password reset requests, etc. Emails sent via our service are deleted immediately. You can use your own
|
||||
email service by setting up the <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the URL of the email service and use the Test button to ensure it works.
|
||||
You can reset these settings to default at any time. There is no way to disable emails for authentication, although you are not required to use a
|
||||
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
|
||||
Registration/confirmation emails will not be sent if you are not accessing Kavita via a publicly reachable URL or unless the Host Name feature is configured.
|
||||
<span class="text-warning">If you want Send to Device to work you must host your own email service.</span>
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label for="settings-emailservice" class="form-label">Email Service URL</label><i class="ms-1 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="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url">
|
||||
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
<h4>{{t('title')}}</h4>
|
||||
<p [innerHTML]="t('description', {link: link}) | safeHtml">
|
||||
<span class="text-warning">{{t('send-to-warning')}}</span>
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label for="settings-emailservice" class="form-label">{{t('email-url-label')}}</label><i class="ms-1 fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #emailServiceTooltip>{{t('email-url-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url">
|
||||
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
|
||||
{{t('reset')}}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
|
||||
{{t('test')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-hostname" class="form-label">Host Name</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #hostNameTooltip>Domain Name (of Reverse Proxy). If set, email generation will always use this.</ng-template>
|
||||
<span class="visually-hidden" id="settings-hostname-help">Domain Name (of Reverse Proxy). If set, email generation will always use this.</span>
|
||||
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
|
||||
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
|
||||
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
|
||||
Host name must start with http(s) and not end in /
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-hostname-help">
|
||||
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
|
||||
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
|
||||
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
|
||||
{{t('host-name-validation')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.dirty">Save</button>
|
||||
</div>
|
||||
<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()">{{t('reset-to-default')}}</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,31 +1,37 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs';
|
||||
import { SettingsService, EmailTestResult } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgIf, NgTemplateOutlet } from '@angular/common';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs';
|
||||
import {EmailTestResult, SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgIf, NgTemplateOutlet} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-email-settings',
|
||||
templateUrl: './manage-email-settings.component.html',
|
||||
styleUrls: ['./manage-email-settings.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet]
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe]
|
||||
})
|
||||
export class ManageEmailSettingsComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
|
||||
link = '<a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a>';
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService, private translocoService: TranslocoService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
|
||||
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, []));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -33,18 +39,19 @@ export class ManageEmailSettingsComponent implements OnInit {
|
|||
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
|
||||
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
|
||||
this.settingsForm.markAsPristine();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const modelSettings = Object.assign({}, this.serverSettings);
|
||||
modelSettings.emailServiceUrl = this.settingsForm.get('emailServiceUrl')?.value;
|
||||
modelSettings.hostName = this.settingsForm.get('hostName')?.value;
|
||||
|
||||
|
||||
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -54,7 +61,7 @@ export class ManageEmailSettingsComponent implements OnInit {
|
|||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -64,7 +71,7 @@ export class ManageEmailSettingsComponent implements OnInit {
|
|||
this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
|
||||
this.resetForm();
|
||||
this.toastr.success('Email Service Reset');
|
||||
this.toastr.success(this.translocoService.translate('toasts.email-service-reset'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -74,15 +81,15 @@ export class ManageEmailSettingsComponent implements OnInit {
|
|||
if (this.settingsForm.get('emailServiceUrl')?.value === '') return;
|
||||
this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value).pipe(take(1)).subscribe(async (result: EmailTestResult) => {
|
||||
if (result.successful) {
|
||||
this.toastr.success('Email Service was reachable');
|
||||
this.toastr.success(this.translocoService.translate('toasts.email-service-reachable'));
|
||||
} else {
|
||||
this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
|
||||
this.toastr.error(this.translocoService.translate('toasts.email-service-unresponsive') + result.errorMessage);
|
||||
}
|
||||
|
||||
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,37 +1,41 @@
|
|||
<div class="container-fluid">
|
||||
<ng-container *transloco="let t; read: 'manage-library'">
|
||||
<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-end" (click)="addLibrary()" title="Add Library"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden"> Add Library</span></button></div>
|
||||
<div class="col-8"><h3>{{t('title')}}</h3></div>
|
||||
<div class="col-4"><button class="btn btn-primary float-end" (click)="addLibrary()" [title]="t('add-library')">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let library of libraries; let idx = index; trackBy: libraryTrackBy" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
<span id="library-name--{{idx}}"><a [routerLink]="'/library/' + library.id">{{library.name}}</a></span>
|
||||
<div class="float-end">
|
||||
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" ngbTooltip="Scan Library" 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>
|
||||
<li *ngFor="let library of libraries; let idx = index; trackBy: libraryTrackBy" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
<span id="library-name--{{idx}}"><a [routerLink]="'/library/' + library.id">{{library.name}}</a></span>
|
||||
<div class="float-end">
|
||||
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
||||
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
||||
</div>
|
||||
<div>Type: {{library.type | libraryType}}</div>
|
||||
<div>Shared Folders: {{library.folders.length + ' folders'}}</div>
|
||||
<div>
|
||||
Last Scanned:
|
||||
<span *ngIf="library.lastScanned === '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{library.lastScanned | timeAgo}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loading" class="list-group-item">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="libraries.length === 0 && !loading">
|
||||
There are no libraries. Try creating one.
|
||||
</li>
|
||||
</h4>
|
||||
</div>
|
||||
<div>{{t('type-title')}} {{library.type | libraryType}}</div>
|
||||
<div>{{t('shared-folders-title')}} {{library.folders.length + ' folders'}}</div>
|
||||
<div>
|
||||
{{t('last-scanned-title')}}
|
||||
<span *ngIf="library.lastScanned === '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{library.lastScanned | timeAgo | defaultDate}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loading" class="list-group-item">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="invisible">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="libraries.length === 0 && !loading">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -22,6 +22,8 @@ import { TimeAgoPipe } from '../../pipe/time-ago.pipe';
|
|||
import { LibraryTypePipe } from '../../pipe/library-type.pipe';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { NgFor, NgIf } from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-library',
|
||||
|
@ -29,7 +31,7 @@ import { NgFor, NgIf } from '@angular/common';
|
|||
styleUrls: ['./manage-library.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgFor, RouterLink, NgbTooltip, NgIf, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe]
|
||||
imports: [NgFor, RouterLink, NgbTooltip, NgIf, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe]
|
||||
})
|
||||
export class ManageLibraryComponent implements OnInit {
|
||||
|
||||
|
@ -116,20 +118,20 @@ export class ManageLibraryComponent implements OnInit {
|
|||
}
|
||||
|
||||
async deleteLibrary(library: Library) {
|
||||
if (await this.confirmService.confirm('Are you sure you want to delete the ' + library.name + ' library? You cannot undo this action.')) {
|
||||
if (await this.confirmService.confirm(translate('toast.confirm-library-delete', {name: library.name}))) {
|
||||
this.deletionInProgress = true;
|
||||
this.libraryService.delete(library.id).pipe(take(1)).subscribe(() => {
|
||||
this.deletionInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
this.getLibraries();
|
||||
this.toastr.success('Library ' + library.name + ' has been removed');
|
||||
this.toastr.success(translate('toasts.library-deleted', {name: library.name}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scanLibrary(library: Library) {
|
||||
this.libraryService.scan(library.id).pipe(take(1)).subscribe(() => {
|
||||
this.toastr.info('A scan has been queued for ' + library.name);
|
||||
this.toastr.info(translate('toasts.scan-queued', {name: library.name}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,75 +1,79 @@
|
|||
<div class="container-fluid">
|
||||
<ng-container *transloco="let t; read: 'manage-media-settings'">
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
|
||||
|
||||
<div class="row g-0">
|
||||
<p>WebP/AVIF can drastically reduce space requirements for files. WebP/AVIF is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use WebP</a> or <a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">Can I Use AVIF</a>.
|
||||
<b>You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.</b></p>
|
||||
<div *ngIf="settingsForm.get('encodeMediaAs')?.dirty" class="alert alert-danger" role="alert">You must trigger the media conversion task in Tasks Tab.</div>
|
||||
<div class="col-md-6 col-sm-12 mb-3">
|
||||
<label for="settings-media-encodeMediaAs" class="form-label me-1">Save Media As</label>
|
||||
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #encodeMediaAsTooltip>All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.</ng-template>
|
||||
<span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
|
||||
<select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
|
||||
<option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<p>{{t('encode-as-description-part-1')}} <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-2')}}</a>/<a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-3')}}</a>
|
||||
<br/><b>{{t('encode-as-warning')}}</b>
|
||||
</p>
|
||||
<div *ngIf="settingsForm.get('encodeMediaAs')?.dirty" class="alert alert-danger" role="alert">{{t('media-warning')}}</div>
|
||||
<div class="col-md-6 col-sm-12 mb-3">
|
||||
<label for="settings-media-encodeMediaAs" class="form-label me-1">{{t('encode-as-label')}}</label>
|
||||
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #encodeMediaAsTooltip>{{t('encode-as-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
|
||||
<select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
|
||||
<option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="settings-bookmarksdir" class="form-label">Bookmarks Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed; other files within directory will be deleted. If Docker, mount an additional volume and use that.</ng-template>
|
||||
<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">
|
||||
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="settings-bookmarksdir" class="form-label">{{t('bookmark-dir-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #bookmarksDirectoryTooltip>{{t('bookmark-dir-tooltip')}}</ng-template>
|
||||
<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">
|
||||
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
|
||||
{{t('change')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.dirty">Save</button>
|
||||
</div>
|
||||
<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()">{{t('reset-to-default')}}</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton>
|
||||
Media Issues <span class="ms-1" *ngIf="alertCount > 0">({{alertCount}})</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<app-manage-alerts (alertCount)="alertCount = $event"></app-manage-alerts>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton>
|
||||
{{t('media-issue-title')}} <span class="ms-1" *ngIf="alertCount > 0">({{alertCount}})</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<app-manage-alerts (alertCount)="alertCount = $event"></app-manage-alerts>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton>
|
||||
Scrobble Issues <span class="ms-1" *ngIf="scrobbleCount > 0">({{scrobbleCount}})</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<app-manage-scrobble-errors (scrobbleCount)="scrobbleCount = $event"></app-manage-scrobble-errors>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton>
|
||||
{{t('scrobble-issue-title')}} <span class="ms-1" *ngIf="scrobbleCount > 0">({{scrobbleCount}})</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<app-manage-scrobble-errors (scrobbleCount)="scrobbleCount = $event"></app-manage-scrobble-errors>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,22 +1,35 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
|
||||
import { NgbModal, NgbTooltip, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EncodeFormats } from '../_models/encode-format';
|
||||
import { ManageScrobbleErrorsComponent } from '../manage-scrobble-errors/manage-scrobble-errors.component';
|
||||
import { ManageAlertsComponent } from '../manage-alerts/manage-alerts.component';
|
||||
import { NgIf, NgTemplateOutlet, NgFor } from '@angular/common';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs';
|
||||
import {SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {DirectoryPickerComponent, DirectoryPickerResult} from '../_modals/directory-picker/directory-picker.component';
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse,
|
||||
NgbAccordionDirective,
|
||||
NgbAccordionHeader,
|
||||
NgbAccordionItem,
|
||||
NgbAccordionToggle,
|
||||
NgbCollapse,
|
||||
NgbModal,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {EncodeFormats} from '../_models/encode-format';
|
||||
import {ManageScrobbleErrorsComponent} from '../manage-scrobble-errors/manage-scrobble-errors.component';
|
||||
import {ManageAlertsComponent} from '../manage-alerts/manage-alerts.component';
|
||||
import {NgFor, NgIf, NgTemplateOutlet} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-media-settings',
|
||||
templateUrl: './manage-media-settings.component.html',
|
||||
styleUrls: ['./manage-media-settings.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, NgFor, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, ManageAlertsComponent, ManageScrobbleErrorsComponent]
|
||||
selector: 'app-manage-media-settings',
|
||||
templateUrl: './manage-media-settings.component.html',
|
||||
styleUrls: ['./manage-media-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, NgFor, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, ManageAlertsComponent, ManageScrobbleErrorsComponent, TranslocoModule]
|
||||
})
|
||||
export class ManageMediaSettingsComponent implements OnInit {
|
||||
|
||||
|
@ -26,6 +39,9 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
alertCount: number = 0;
|
||||
scrobbleCount: number = 0;
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
get EncodeFormats() { return EncodeFormats; }
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { }
|
||||
|
@ -35,6 +51,7 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
this.serverSettings = settings;
|
||||
this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, [Validators.required]));
|
||||
this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required]));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -42,6 +59,7 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
|
||||
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
|
||||
this.settingsForm.markAsPristine();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
|
@ -52,7 +70,7 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -62,7 +80,7 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -76,6 +94,7 @@ export class ManageMediaSettingsComponent implements OnInit {
|
|||
if (closeResult.success && closeResult.folderPath !== '') {
|
||||
this.settingsForm.get(formControl)?.setValue(closeResult.folderPath);
|
||||
this.settingsForm.markAsDirty();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,56 +1,57 @@
|
|||
<p>This table contains issues found during scrobbling. This list is non-managed.
|
||||
You can clear it at any time and wait for the next scrobble upload to see. If there is an unknown series, you are best correcting the
|
||||
series name or localized series name or adding a weblink for the providers.</p>
|
||||
<ng-container *transloco="let t; read: 'manage-scrobble-errors'">
|
||||
<p>{{t('description')}}</p>
|
||||
|
||||
<form [formGroup]="formGroup">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" placeholder="Filter" formControlName="filter" />
|
||||
<button class="btn btn-primary" type="button" (click)="clear()">Clear Errors</button>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
||||
<button class="btn btn-primary" type="button" (click)="clear()">{{t('clear-errors')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="table table-striped table-hover table-sm table-hover">
|
||||
</form>
|
||||
<table class="table table-striped table-hover table-sm table-hover">
|
||||
<thead #header>
|
||||
<tr>
|
||||
<th scope="col" sortable="seriesId" (sort)="onSort($event)">
|
||||
Series
|
||||
</th>
|
||||
<th scope="col" sortable="created" (sort)="onSort($event)">
|
||||
Created
|
||||
</th>
|
||||
<th scope="col" sortable="comment" (sort)="onSort($event)">
|
||||
Comment
|
||||
</th>
|
||||
<th scope="col" sortable="seriesId" (sort)="onSort($event)">
|
||||
{{t('series-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="created" (sort)="onSort($event)">
|
||||
{{t('created-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="comment" (sort)="onSort($event)">
|
||||
{{t('comment-header')}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
Edit
|
||||
{{t('edit-header')}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody #container>
|
||||
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
|
||||
<ng-container *ngIf="data | filter: filterList as filteredData">
|
||||
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
|
||||
<tr *ngFor="let item of filteredData; index as i">
|
||||
<td>
|
||||
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{item.created | date:'shortDate'}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
|
||||
<i class="fa fa-pen me-1" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Edit {{item.details}}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
|
||||
<tr *ngFor="let item of filteredData; index as i">
|
||||
<td>
|
||||
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{item.created | date:'shortDate'}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
|
||||
<i class="fa fa-pen me-1" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -25,11 +25,12 @@ import {EditSeriesModalComponent} from "../../cards/_modals/edit-series-modal/ed
|
|||
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {FilterPipe} from "../../pipe/filter.pipe";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-scrobble-errors',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader],
|
||||
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule],
|
||||
templateUrl: './manage-scrobble-errors.component.html',
|
||||
styleUrls: ['./manage-scrobble-errors.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,184 +1,205 @@
|
|||
<div class="container-fluid">
|
||||
<ng-container *transloco="let t; read: 'manage-settings'">
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Notice:</strong> Changing Port, Base Url, Cache Size or IPs requires a manual restart of Kavita to take effect.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-baseurl" class="form-label">Base Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita. Not supported on Docker using non-root user.</ng-template>
|
||||
<span class="visually-hidden" id="settings-cachedir-help">Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita. Not supported on Docker using non-root user.</span>
|
||||
<div class="input-group">
|
||||
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
|
||||
[class.is-invalid]="settingsForm.get('baseUrl')?.invalid && settingsForm.get('baseUrl')?.touched">
|
||||
<button class="btn btn-outline-secondary" (click)="resetBaseUrl()">Reset</button>
|
||||
<div class="mb-3">
|
||||
<label for="settings-baseurl" class="form-label">{{t('base-url-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #baseUrlTooltip>{{t('base-url-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-cachedir-help">
|
||||
<ng-container [ngTemplateOutlet]="baseUrlTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
|
||||
[class.is-invalid]="settingsForm.get('baseUrl')?.invalid && settingsForm.get('baseUrl')?.touched">
|
||||
<button class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
|
||||
</div>
|
||||
<div id="baseurl-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('baseUrl')?.errors?.pattern">
|
||||
{{t('base-url-validation')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-8 col-sm-12 pe-2">
|
||||
<label for="settings-ipaddresses" class="form-label">{{t('ip-address-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="ipAddressesTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #ipAddressesTooltip>{{t('ip-address-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-ipaddresses-help">
|
||||
<ng-container [ngTemplateOutlet]="ipAddressesTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
|
||||
[class.is-invalid]="settingsForm.get('ipAddresses')?.invalid && settingsForm.get('ipAddresses')?.touched">
|
||||
<button class="btn btn-outline-secondary" (click)="resetIPAddresses()">Reset</button>
|
||||
</div>
|
||||
<div id="ipaddresses-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('ipAddresses')?.errors?.pattern">
|
||||
{{t('ip-address-validation')}}
|
||||
</div>
|
||||
<div id="baseurl-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('baseUrl')?.errors?.pattern">
|
||||
Base URL must start and end with /
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-8 col-sm-12 pe-2">
|
||||
<label for="settings-ipaddresses" class="form-label">IP Addresses</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="ipAddressesTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #ipAddressesTooltip>This does not apply to Docker</ng-template>
|
||||
<span class="visually-hidden" id="settings-ipaddresses-help">Comma separated list of Ip addresses the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
|
||||
<div class="input-group">
|
||||
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
|
||||
[class.is-invalid]="settingsForm.get('ipAddresses')?.invalid && settingsForm.get('ipAddresses')?.touched">
|
||||
<button class="btn btn-outline-secondary" (click)="resetIPAddresses()">Reset</button>
|
||||
</div>
|
||||
<div id="ipaddresses-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('ipAddresses')?.errors?.pattern">
|
||||
IP addresses can only contain valid IPv4 or IPv6 addresses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="settings-port" class="form-label">{{t('port-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #portTooltip>{{t('port-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-port-help">
|
||||
<ng-container [ngTemplateOutlet]="portTooltip"></ng-container>
|
||||
</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>
|
||||
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="settings-port" class="form-label">Port</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
|
||||
<span class="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="row g-0 mb-2">
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="backup-tasks" class="form-label">{{t('backup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="backupTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #backupTasksTooltip>{{t('backup-tooltip')}}.</ng-template>
|
||||
<span class="visually-hidden" id="backup-tasks-help">
|
||||
<ng-container [ngTemplateOutlet]="backupTasksTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="backup-tasks" aria-describedby="backup-tasks-help" class="form-control" formControlName="totalBackups"
|
||||
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalBackups')?.invalid && settingsForm.get('totalBackups')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalBackups')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
{{t('min-backup-validation')}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.max">
|
||||
{{t('max-backup-validation', {num: errors.max.max})}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('field-required')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="backup-tasks" class="form-label">Days of Backups</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="backupTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #backupTasksTooltip>The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
|
||||
<span class="visually-hidden" id="backup-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
|
||||
<input id="backup-tasks" aria-describedby="backup-tasks-help" class="form-control" formControlName="totalBackups"
|
||||
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalBackups')?.invalid && settingsForm.get('totalBackups')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalBackups')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
You must have at least 1 backup
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.max">
|
||||
You cannot have more than {{errors.max.max}} backups
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="log-tasks" class="form-label">Days of Logs</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="logTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #logTasksTooltip>The number of logs to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
|
||||
<span class="visually-hidden" id="log-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
|
||||
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs"
|
||||
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalLogs')?.invalid && settingsForm.get('totalLogs')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalLogs')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
You must have at least 1 log
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.max">
|
||||
You cannot have more than {{errors.max.max}} logs
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<label for="logging-level-port" class="form-label">Logging Level</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space.</ng-template>
|
||||
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on.</span>
|
||||
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel"
|
||||
[class.is-invalid]="settingsForm.get('loggingLevel')?.invalid && settingsForm.get('loggingLevel')?.touched">
|
||||
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="log-tasks" class="form-label">{{t('log-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="logTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #logTasksTooltip>{{t('log-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="log-tasks-help">
|
||||
<ng-container [ngTemplateOutlet]="logTasksTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs"
|
||||
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalLogs')?.invalid && settingsForm.get('totalLogs')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalLogs')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
{{t('min-log-validation')}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.max">
|
||||
{{t('max-logs-validation', {num: errors.max.max})}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('field-required')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2 mt-3">
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="cache-size" class="form-label">Cache Size</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #cacheSizeTooltip>The amount of memory allowed for caching heavy APIs. Default is 75MB.</ng-template>
|
||||
<span class="visually-hidden" id="cache-size-help">The amount of memory allowed for caching heavy APIs. Default is 50MB.</span>
|
||||
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
|
||||
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('cacheSize')?.invalid && settingsForm.get('cacheSize')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('cacheSize')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
You must have at least 50 MB.
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="on-deck-progress-days" class="form-label">On Deck Last Progress (days)</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="onDeckProgressDaysTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #onDeckProgressDaysTooltip>The number of days since last progress before kicking something off On Deck.</ng-template>
|
||||
<span class="visually-hidden" id="on-deck-progress-days-help">The number of days since last progress before kicking something off On Deck.</span>
|
||||
<input id="on-deck-progress-days" aria-describedby="on-deck-progress-days-help" class="form-control" formControlName="onDeckProgressDays"
|
||||
type="number" inputmode="numeric" step="1" min="1"
|
||||
[class.is-invalid]="settingsForm.get('onDeckProgressDays')?.invalid && settingsForm.get('onDeckProgressDays')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('onDeckProgressDays')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
Must be at least 1 day
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="on-deck-update-days" class="form-label">On Deck Last Chapter Add (days)</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="onDeckUpdateDaysTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #onDeckUpdateDaysTooltip>The number of days since last chapter was added to include something On Deck.</ng-template>
|
||||
<span class="visually-hidden" id="on-deck-update-days-help">The number of days since last chapter was added to include something On Deck.</span>
|
||||
<input id="on-deck-update-days" aria-describedby="on-deck-update-days-help" class="form-control" formControlName="onDeckUpdateDays"
|
||||
type="number" inputmode="numeric" step="1" min="1"
|
||||
[class.is-invalid]="settingsForm.get('onDeckUpdateDays')?.invalid && settingsForm.get('onDeckUpdateDays')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('onDeckUpdateDays')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
Must be at least 1 day
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<label for="logging-level-port" class="form-label">{{t('logging-level-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #loggingLevelTooltip>{{t('logging-level-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="logging-level-port-help">
|
||||
<ng-container [ngTemplateOutlet]="loggingLevelTooltip"></ng-container>
|
||||
</span>
|
||||
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel"
|
||||
[class.is-invalid]="settingsForm.get('loggingLevel')?.invalid && settingsForm.get('loggingLevel')?.touched">
|
||||
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 mt-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 data to Kavita's servers. This includes information on certain features used, number of files, OS version, Kavita install version, CPU, and memory. We will use this information to prioritize features, bug fixes, and performance tuning. Requires restart to take effect. See the <a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
|
||||
<div class="form-check form-switch">
|
||||
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
|
||||
<label for="stat-collection" class="form-check-label">Send Data</label>
|
||||
</div>
|
||||
<div class="row g-0 mb-2 mt-3">
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="cache-size" class="form-label">{{t('cache-size-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #cacheSizeTooltip>{{t('cache-size-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="cache-size-help">
|
||||
<ng-container [ngTemplateOutlet]="cacheSizeTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
|
||||
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('cacheSize')?.invalid && settingsForm.get('cacheSize')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('cacheSize')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
{{t('min-cache-validation')}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('field-required')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="on-deck-progress-days" class="form-label">{{t('on-deck-last-progress-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="onDeckProgressDaysTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #onDeckProgressDaysTooltip>{{t('on-deck-last-progress-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="on-deck-progress-days-help">
|
||||
<ng-container [ngTemplateOutlet]="onDeckProgressDaysTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="on-deck-progress-days" aria-describedby="on-deck-progress-days-help" class="form-control" formControlName="onDeckProgressDays"
|
||||
type="number" inputmode="numeric" step="1" min="1"
|
||||
[class.is-invalid]="settingsForm.get('onDeckProgressDays')?.invalid && settingsForm.get('onDeckProgressDays')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('onDeckProgressDays')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
{{t('min-days-validation')}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('field-required')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-2">
|
||||
<label for="on-deck-update-days" class="form-label">{{t('on-deck-last-chapter-add-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="onDeckUpdateDaysTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #onDeckUpdateDaysTooltip>{{t('on-deck-last-chapter-add-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="on-deck-update-days-help">
|
||||
<ng-container [ngTemplateOutlet]="onDeckUpdateDaysTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="on-deck-update-days" aria-describedby="on-deck-update-days-help" class="form-control" formControlName="onDeckUpdateDays"
|
||||
type="number" inputmode="numeric" step="1" min="1"
|
||||
[class.is-invalid]="settingsForm.get('onDeckUpdateDays')?.invalid && settingsForm.get('onDeckUpdateDays')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('onDeckUpdateDays')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
{{t('min-days-validation')}}
|
||||
</p>
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('field-required')}}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.</p>
|
||||
<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>
|
||||
<div class="mb-3 mt-3">
|
||||
<label for="stat-collection" class="form-label" aria-describedby="collection-info">{{t('allow-stats-label')}}</label>
|
||||
<p class="accent" id="collection-info">{{t('allow-stats-tooltip-part-1')}}<a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> {{t('allow-stats-tooltip-part-2')}}</p>
|
||||
<div class="form-check form-switch">
|
||||
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
|
||||
<label for="stat-collection" class="form-check-label">{{t('send-data')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="folder-watching" class="form-label" aria-describedby="folder-watching-info">Folder Watching</label>
|
||||
<p class="accent" id="folder-watching-info">Allows Kavita to monitor Library Folders to detect changes and invoke scanning on those changes. This allows content to be updated without manually invoking scans or waiting for nightly scans.</p>
|
||||
<div class="form-check form-switch">
|
||||
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
|
||||
<label for="folder-watching" class="form-check-label">Enable Folder Watching</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="opds" aria-describedby="opds-info" class="form-label">{{t('opds-label')}}</label>
|
||||
<p class="accent" id="opds-info">{{t('opds-tooltip')}}</p>
|
||||
<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">{{t('enable-opds')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.dirty">Save</button>
|
||||
<div class="mb-3">
|
||||
<label for="folder-watching" class="form-label" aria-describedby="folder-watching-info">{{t('folder-watching-label')}}</label>
|
||||
<p class="accent" id="folder-watching-info">{{t('folder-watching-tooltip')}}</p>
|
||||
<div class="form-check form-switch">
|
||||
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
|
||||
<label for="folder-watching" class="form-check-label">{{t('enable-folder-watching')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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()">{{t('reset-to-default')}}</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,22 +1,23 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, Validators, FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { TagBadgeCursor } from 'src/app/shared/tag-badge/tag-badge.component';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgIf, NgFor, TitleCasePipe } from '@angular/common';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-settings',
|
||||
templateUrl: './manage-settings.component.html',
|
||||
styleUrls: ['./manage-settings.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, TitleCasePipe]
|
||||
selector: 'app-manage-settings',
|
||||
templateUrl: './manage-settings.component.html',
|
||||
styleUrls: ['./manage-settings.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, TitleCasePipe, TranslocoModule, NgTemplateOutlet]
|
||||
})
|
||||
export class ManageSettingsComponent implements OnInit {
|
||||
|
||||
|
@ -24,6 +25,8 @@ export class ManageSettingsComponent implements OnInit {
|
|||
settingsForm: FormGroup = new FormGroup({});
|
||||
taskFrequencies: Array<string> = [];
|
||||
logLevels: Array<string> = [];
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||
private serverService: ServerService) { }
|
||||
|
@ -31,9 +34,11 @@ export class ManageSettingsComponent implements OnInit {
|
|||
ngOnInit(): void {
|
||||
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
|
||||
this.taskFrequencies = frequencies;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.settingsService.getLoggingLevels().pipe(take(1)).subscribe(levels => {
|
||||
this.logLevels = levels;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
|
@ -60,9 +65,12 @@ export class ManageSettingsComponent implements OnInit {
|
|||
if (info.isDocker) {
|
||||
this.settingsForm.get('ipAddresses')?.disable();
|
||||
this.settingsForm.get('port')?.disable();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
})
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
|
@ -85,6 +93,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsForm.get('onDeckProgressDays')?.setValue(this.serverSettings.onDeckProgressDays);
|
||||
this.settingsForm.get('onDeckUpdateDays')?.setValue(this.serverSettings.onDeckUpdateDays);
|
||||
this.settingsForm.markAsPristine();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
@ -93,7 +102,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -103,7 +112,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -113,7 +122,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsService.resetIPAddressesSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings.ipAddresses = settings.ipAddresses;
|
||||
this.settingsForm.get('ipAddresses')?.setValue(this.serverSettings.ipAddresses);
|
||||
this.toastr.success('IP Addresses Reset');
|
||||
this.toastr.success(this.translocoService.translate('toasts.reset-ip-address'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -123,11 +132,10 @@ export class ManageSettingsComponent implements OnInit {
|
|||
this.settingsService.resetBaseUrl().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings.baseUrl = settings.baseUrl;
|
||||
this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl);
|
||||
this.toastr.success('Base Url Reset');
|
||||
this.toastr.success(this.translocoService.translate('toasts.reset-base-url'));
|
||||
this.cdRef.markForCheck();
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,42 +1,43 @@
|
|||
<div class="container-fluid">
|
||||
<h3>About System</h3>
|
||||
<ng-container *transloco="let t; read: 'manage-system'">
|
||||
<div class="container-fluid">
|
||||
<h3>{{t('title')}}</h3>
|
||||
<hr/>
|
||||
<div class="mb-3" *ngIf="serverInfo">
|
||||
<dl>
|
||||
<dt>Version</dt>
|
||||
<dd>{{serverInfo.kavitaVersion}}</dd>
|
||||
<dl>
|
||||
<dt>{{t('version-title')}}</dt>
|
||||
<dd>{{serverInfo.kavitaVersion}}</dd>
|
||||
|
||||
<dt>Install ID</dt>
|
||||
<dd>{{serverInfo.installId}}</dd>
|
||||
</dl>
|
||||
<dt>{{t('installId-title')}}</dt>
|
||||
<dd>{{serverInfo.installId}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h3>More Info</h3>
|
||||
<h3>{{t('more-info-title')}}</h3>
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col-4">Home page:</div>
|
||||
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Wiki:</div>
|
||||
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Discord:</div>
|
||||
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Donations:</div>
|
||||
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Source:</div>
|
||||
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">Feature Requests:</div>
|
||||
<div class="col"><a href="https://feats.kavitareader.com" target="_blank" rel="noopener noreferrer">https://feats.kavitareader.com</a><br/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('home-page-title')}}</div>
|
||||
<div class="col"><a href="https://www.kavitareader.com" target="_blank" rel="noopener noreferrer">kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('wiki-title')}}</div>
|
||||
<div class="col"><a href="https://wiki.kavitareader.com" target="_blank" rel="noopener noreferrer">wiki.kavitareader.com</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('discord-title')}}</div>
|
||||
<div class="col"><a href="https://discord.gg/b52wT37kt7" target="_blank" rel="noopener noreferrer">discord.gg/b52wT37kt7</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('donations-title')}}</div>
|
||||
<div class="col"><a href="https://opencollective.com/kavita" target="_blank" rel="noopener noreferrer">opencollective.com/kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('source-title')}}</div>
|
||||
<div class="col"><a href="https://github.com/Kareadita/Kavita" target="_blank" rel="noopener noreferrer">github.com/Kareadita/Kavita</a></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">{{t('feature-request-title')}}</div>
|
||||
<div class="col"><a href="https://feats.kavitareader.com" target="_blank" rel="noopener noreferrer">https://feats.kavitareader.com</a><br/></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -7,13 +7,14 @@ import { SettingsService } from '../settings.service';
|
|||
import {ServerInfoSlim} from '../_models/server-info';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-system',
|
||||
templateUrl: './manage-system.component.html',
|
||||
styleUrls: ['./manage-system.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf]
|
||||
imports: [NgIf, TranslocoModule]
|
||||
})
|
||||
export class ManageSystemComponent implements OnInit {
|
||||
|
||||
|
@ -58,7 +59,7 @@ export class ManageSystemComponent implements OnInit {
|
|||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
|
|
@ -1,73 +1,76 @@
|
|||
<div class="container-fluid">
|
||||
<ng-container *transloco="let t; read: 'manage-tasks-settings'">
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<h4>Recurring Tasks</h4>
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-scan" class="form-label">Library Scan</label> <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 metadata around library files.</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-scan-help">How often Kavita will scan and refresh metadata around library 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>
|
||||
<h4>{{t('title')}}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-scan" class="form-label">{{t('library-scan-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskScanTooltip>{{t('library-scan-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-scan-help"><ng-container [ngTemplateOutlet]="taskScanTooltip"></ng-container></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="mb-3">
|
||||
<label for="settings-tasks-backup" class="form-label">Library Database Backup</label> <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="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="mb-3">
|
||||
<label for="settings-tasks-backup" class="form-label">{{t('library-database-backup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskBackupTooltip>{{t('library-database-backup-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-backup-help"><ng-container [ngTemplateOutlet]="taskBackupTooltip"></ng-container></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>
|
||||
|
||||
<h4>Ad-hoc Tasks</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job Title</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of adhocTasks; let idx = index;">
|
||||
<td id="adhoctask--{{idx}}">
|
||||
{{task.name}}
|
||||
</td>
|
||||
<td>
|
||||
{{task.description}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" (click)="runAdhoc(task)" attr.aria-labelledby="adhoctask--{{idx}}">Run</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>{{t('adhoc-tasks-title')}}</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{t('job-title-header')}}</th>
|
||||
<th scope="col">{{t('description-header')}}</th>
|
||||
<th scope="col">{{t('action-header')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of adhocTasks; let idx = index;">
|
||||
<td id="adhoctask--{{idx}}">
|
||||
{{t(task.name)}}
|
||||
</td>
|
||||
<td>
|
||||
{{t(task.description)}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" (click)="runAdhoc(task)" attr.aria-labelledby="adhoctask--{{idx}}">Run</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Recurring Tasks</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job Title</th>
|
||||
<th scope="col">Last Executed</th>
|
||||
<th scope="col">Cron</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of recurringTasks$ | async; index as i">
|
||||
<td>
|
||||
{{task.title | titlecase}}
|
||||
</td>
|
||||
<td>{{task.lastExecution | date:'short' | defaultValue }}</td>
|
||||
<td>{{task.cron}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>{{t('recurring-tasks-title')}}</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{t('job-title-header')}}</th>
|
||||
<th scope="col">{{t('last-executed-header')}}</th>
|
||||
<th scope="col">{{t('cron-header')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of recurringTasks$ | async; index as i">
|
||||
<td>
|
||||
{{task.title | titlecase}}
|
||||
</td>
|
||||
<td>{{task.lastExecution | date:'short' | defaultValue }}</td>
|
||||
<td>{{task.cron}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<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.dirty">Save</button>
|
||||
</div>
|
||||
<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()">{{t('reset-to-default')}}</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { shareReplay, take } from 'rxjs/operators';
|
||||
import { defer, forkJoin, Observable, of } from 'rxjs';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Job } from 'src/app/_models/job/job';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { DefaultValuePipe } from '../../pipe/default-value.pipe';
|
||||
import { NgIf, NgFor, AsyncPipe, TitleCasePipe, DatePipe } from '@angular/common';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {shareReplay, take} from 'rxjs/operators';
|
||||
import {defer, forkJoin, Observable, of} from 'rxjs';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {Job} from 'src/app/_models/job/job';
|
||||
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {DefaultValuePipe} from '../../pipe/default-value.pipe';
|
||||
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
interface AdhocTask {
|
||||
name: string;
|
||||
|
@ -22,14 +23,17 @@ interface AdhocTask {
|
|||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-tasks-settings',
|
||||
templateUrl: './manage-tasks-settings.component.html',
|
||||
styleUrls: ['./manage-tasks-settings.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe]
|
||||
selector: 'app-manage-tasks-settings',
|
||||
templateUrl: './manage-tasks-settings.component.html',
|
||||
styleUrls: ['./manage-tasks-settings.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet]
|
||||
})
|
||||
export class ManageTasksSettingsComponent implements OnInit {
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
taskFrequencies: Array<string> = [];
|
||||
|
@ -39,55 +43,55 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
// noinspection JSVoidFunctionReturnValueUsed
|
||||
adhocTasks: Array<AdhocTask> = [
|
||||
{
|
||||
name: 'Convert Media to Target Encoding',
|
||||
description: 'Runs a long-running task which will convert all kavita-managed media to the target encoding. This is slow (especially on ARM devices).',
|
||||
name: 'convert-media-task',
|
||||
description: 'convert-media-task-desc',
|
||||
api: this.serverService.convertMedia(),
|
||||
successMessage: 'Conversion of Media to Target Encoding has been queued'
|
||||
successMessage: 'convert-media-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Bust Cache',
|
||||
description: 'Busts the Kavita+ Cache - should only be used when debugging bad matches.',
|
||||
name: 'bust-cache-task',
|
||||
description: 'bust-cache-task-desc',
|
||||
api: this.serverService.bustCache(),
|
||||
successMessage: 'Kavita+ Cache busted'
|
||||
successMessage: 'bust-cache-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Clear Reading Cache',
|
||||
description: 'Clears cached files for reading. Useful when you\'ve just updated a file that you were previously reading within the last 24 hours.',
|
||||
name: 'clear-reading-cache-task',
|
||||
description: 'clear-reading-cache-task-desc',
|
||||
api: this.serverService.clearCache(),
|
||||
successMessage: 'Cache has been cleared'
|
||||
successMessage: 'clear-reading-cache-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Clean up Want to Read',
|
||||
description: 'Removes any series that users have fully read that are within Want to Read and have a publication status of Completed. Runs every 24 hours.',
|
||||
name: 'clean-up-want-to-read-task',
|
||||
description: 'clean-up-want-to-read-task-desc',
|
||||
api: this.serverService.cleanupWantToRead(),
|
||||
successMessage: 'Want to Read has been cleaned up'
|
||||
successMessage: 'clean-up-want-to-read-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Backup Database',
|
||||
description: 'Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files.',
|
||||
name: 'backup-database-task',
|
||||
description: 'backup-database-task-desc',
|
||||
api: this.serverService.backupDatabase(),
|
||||
successMessage: 'A job to backup the database has been queued'
|
||||
successMessage: 'backup-database-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Download Logs',
|
||||
description: 'Compiles all log files into a zip and downloads it.',
|
||||
name: 'download-logs-task',
|
||||
description: 'download-logs-task-desc',
|
||||
api: defer(() => of(this.downloadService.download('logs', undefined))),
|
||||
successMessage: ''
|
||||
},
|
||||
{
|
||||
name: 'Analyze Files',
|
||||
description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.',
|
||||
name: 'analyze-files-task',
|
||||
description: 'analyze-files-task-desc',
|
||||
api: this.serverService.analyzeFiles(),
|
||||
successMessage: 'File analysis has been queued'
|
||||
successMessage: 'analyze-files-task-success'
|
||||
},
|
||||
{
|
||||
name: 'Check for Updates',
|
||||
description: 'See if there are any Stable releases ahead of your version.',
|
||||
name: 'check-for-updates-task',
|
||||
description: 'check-for-updates-task-desc',
|
||||
api: this.serverService.checkForUpdate(),
|
||||
successMessage: '',
|
||||
successFunction: (update) => {
|
||||
if (update === null) {
|
||||
this.toastr.info('No updates available');
|
||||
this.toastr.info(this.translocoService.translate('toasts.no-updates'));
|
||||
return;
|
||||
}
|
||||
const modalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
|
@ -105,23 +109,24 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
frequencies: this.settingsService.getTaskFrequencies(),
|
||||
levels: this.settingsService.getLoggingLevels(),
|
||||
settings: this.settingsService.getServerSettings()
|
||||
}
|
||||
|
||||
).subscribe(result => {
|
||||
}).subscribe(result => {
|
||||
this.taskFrequencies = result.frequencies;
|
||||
this.logLevels = result.levels;
|
||||
this.serverSettings = result.settings;
|
||||
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
|
||||
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan);
|
||||
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
|
||||
this.settingsForm.markAsPristine();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
@ -133,7 +138,8 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
this.cdRef.markForCheck();
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -143,7 +149,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
@ -152,7 +158,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
runAdhoc(task: AdhocTask) {
|
||||
task.api.subscribe((data: any) => {
|
||||
if (task.successMessage.length > 0) {
|
||||
this.toastr.success(task.successMessage);
|
||||
this.toastr.success(this.translocoService.translate('manage-tasks-settings.' + task.successMessage));
|
||||
}
|
||||
|
||||
if (task.successFunction) {
|
||||
|
|
|
@ -1,56 +1,64 @@
|
|||
<ng-container *transloco="let t; read: 'manage-users'">
|
||||
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-8"><h3>Active Users</h3></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"> Invite</span></button></div>
|
||||
<div class="col-8"><h3>{{t('title')}}</h3></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"> {{t('invite')}}</span></button></div>
|
||||
</div>
|
||||
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
<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="visually-hidden">(You)</span>
|
||||
</span>
|
||||
<span class="badge bg-secondary text-dark" *ngIf="member.isPending">Pending</span>
|
||||
<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-primary btn-sm me-2" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
|
||||
|
||||
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)" placement="top" ngbTooltip="Resend Invite" attr.aria-label="Delete Invite {{member.username | titlecase}}">Resend</button>
|
||||
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="setup(member)" placement="top" ngbTooltip="Setup User" attr.aria-label="Setup User {{member.username | titlecase}}">Setup</button>
|
||||
<button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (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>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="user-info">
|
||||
<div>Last Active:
|
||||
<span *ngIf="member.lastActive === '0001-01-01T00:00:00'; else activeDate">Never</span>
|
||||
<ng-template #activeDate>
|
||||
{{member.lastActive | date: 'short'}} <i class="presence fa fa-circle ms-1" title="Online Now" aria-hidden="true" *ngIf="(messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div>
|
||||
<div class="row g-0">
|
||||
<div>
|
||||
Roles: <span *ngIf="getRoles(member).length === 0; else showRoles">None</span>
|
||||
<ng-template #showRoles>
|
||||
<app-tag-badge *ngFor="let role of getRoles(member)" class="col-auto">{{role}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
|
||||
<div>
|
||||
<h4>
|
||||
<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="visually-hidden">{{t('you-alt')}}</span>
|
||||
</span>
|
||||
<span class="badge bg-secondary text-dark" *ngIf="member.isPending">{{t('pending-title')}}</span>
|
||||
<div class="float-end" *ngIf="canEditMember(member)">
|
||||
<button class="btn btn-danger btn-sm me-2" (click)="deleteUser(member)"
|
||||
placement="top" [ngbTooltip]="t('delete-user-tooltip')" [attr.aria-label]="t('delete-user-alt', {user: member.username | titlecase})">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm me-2" (click)="openEditUser(member)"
|
||||
placement="top" [ngbTooltip]="t('edit-user-tooltip')" [attr.aria-label]="t('edit-user-alt', {user: member.username | titlecase})">
|
||||
<i class="fa fa-pen" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)"
|
||||
placement="top" [ngbTooltip]="t('resend-invite-tooltip')" [attr.aria-label]="t('resend-invite-alt', {user: member.username | titlecase})">{{t('resend')}}}</button>
|
||||
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="setup(member)"
|
||||
placement="top" [ngbTooltip]="t('setup-user-tooltip')" [attr.aria-label]="t('setup-user-alt', {user: member.username | titlecase})">Setup</button>
|
||||
<button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (click)="updatePassword(member)"
|
||||
placement="top" [ngbTooltip]="t('change-password-tooltip')" [attr.aria-label]="t('change-password-alt', {user: member.username | titlecase})"><i class="fa fa-key" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loadingMembers" class="list-group-item">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="invisible">Loading...</span>
|
||||
</h4>
|
||||
<div class="user-info">
|
||||
<div>{{t('last-active-title')}}
|
||||
<span>{{member.lastActive | date: 'short' | defaultDate}} <i class="presence fa fa-circle ms-1" [title]="t('online-now-tooltip')" aria-hidden="true" *ngIf="(messageHub.onlineUsers$ | async)?.includes(member.username)"></i></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="members.length === 0 && !loadingMembers">
|
||||
There are no other users.
|
||||
</li>
|
||||
<div *ngIf="!hasAdminRole(member)">{{t('sharing-title')}} {{formatLibraries(member)}}</div>
|
||||
<div class="row g-0">
|
||||
<div>
|
||||
{{t('roles-title')}} <span *ngIf="getRoles(member).length === 0; else showRoles">{{t('none')}}</span>
|
||||
<ng-template #showRoles>
|
||||
<app-tag-badge *ngFor="let role of getRoles(member)" class="col-auto">{{role}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="loadingMembers" class="list-group-item">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="members.length === 0 && !loadingMembers">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -1,35 +1,38 @@
|
|||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { Member } from 'src/app/_models/auth/member';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { InviteUserComponent } from '../invite-user/invite-user.component';
|
||||
import { EditUserComponent } from '../edit-user/edit-user.component';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { TagBadgeComponent } from '../../shared/tag-badge/tag-badge.component';
|
||||
import { NgFor, NgIf, AsyncPipe, TitleCasePipe, DatePipe } from '@angular/common';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {MemberService} from 'src/app/_services/member.service';
|
||||
import {Member} from 'src/app/_models/auth/member';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {ResetPasswordModalComponent} from '../_modals/reset-password-modal/reset-password-modal.component';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
import {MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {InviteUserComponent} from '../invite-user/invite-user.component';
|
||||
import {EditUserComponent} from '../edit-user/edit-user.component';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {Router} from '@angular/router';
|
||||
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
|
||||
import {AsyncPipe, DatePipe, NgFor, NgIf, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
templateUrl: './manage-users.component.html',
|
||||
styleUrls: ['./manage-users.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgFor, NgIf, NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, DatePipe]
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgFor, NgIf, NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, DatePipe, TranslocoModule, DefaultDatePipe]
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit, OnDestroy {
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
|
||||
members: Member[] = [];
|
||||
loggedInUsername = '';
|
||||
loadingMembers = false;
|
||||
|
||||
private onDestroy = new Subject<void>();
|
||||
translocoService = inject(TranslocoService);
|
||||
cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(private memberService: MemberService,
|
||||
private accountService: AccountService,
|
||||
|
@ -42,6 +45,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
this.accountService.currentUser$.pipe(take(1)).subscribe((user) => {
|
||||
if (user) {
|
||||
this.loggedInUsername = user.username;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -51,16 +55,13 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
this.loadMembers();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
loadMembers() {
|
||||
this.loadingMembers = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.memberService.getMembers(true).subscribe(members => {
|
||||
this.members = members;
|
||||
// Show logged in user at the top of the list
|
||||
// Show logged-in user at the top of the list
|
||||
this.members.sort((a: Member, b: Member) => {
|
||||
if (a.username === this.loggedInUsername) return 1;
|
||||
if (b.username === this.loggedInUsername) return 1;
|
||||
|
@ -72,6 +73,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
return 0;
|
||||
})
|
||||
this.loadingMembers = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -86,15 +88,15 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
this.loadMembers();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
async deleteUser(member: Member) {
|
||||
if (await this.confirmService.confirm('Are you sure you want to delete this user?')) {
|
||||
if (await this.confirmService.confirm(this.translocoService.translate('toasts.confirm-delete-user'))) {
|
||||
this.memberService.deleteMember(member.username).subscribe(() => {
|
||||
setTimeout(() => {
|
||||
this.loadMembers();
|
||||
this.toastr.success(member.username + ' has been deleted.');
|
||||
}, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush
|
||||
this.toastr.success(this.translocoService.translate('toasts.user-deleted', {user: member.username}));
|
||||
}, 30); // SetTimeout because I've noticed this can run superfast and not give enough time for data to flush
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -106,17 +108,15 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
log(o: any) {console.log(o)}
|
||||
|
||||
resendEmail(member: Member) {
|
||||
this.serverService.isServerAccessible().subscribe(canAccess => {
|
||||
this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => {
|
||||
if (canAccess) {
|
||||
this.toastr.info('Email sent to ' + member.username);
|
||||
this.toastr.info(this.translocoService.translate('toasts.email-sent-to-user', {user: member.username}));
|
||||
return;
|
||||
}
|
||||
await this.confirmService.alert(
|
||||
'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking. <br/> <a href="' + email + '" target="_blank" rel="noopener noreferrer">' + email + '</a>');
|
||||
this.translocoService.translate('toasts.click-email-link') + '<br/> <a href="' + email + '" target="_blank" rel="noopener noreferrer">' + email + '</a>');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -136,7 +136,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
|
||||
formatLibraries(member: Member) {
|
||||
if (member.libraries.length === 0) {
|
||||
return 'None';
|
||||
return this.translocoService.translate('manage-users.none');
|
||||
}
|
||||
|
||||
return member.libraries.map(item => item.name).join(', ');
|
||||
|
@ -149,5 +149,5 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
|
|||
getRoles(member: Member) {
|
||||
return member.roles.filter(item => item != 'Pleb');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<h4>Roles</h4>
|
||||
<ul class="list-group">
|
||||
<ng-container *transloco="let t; read:'role-selector'">
|
||||
<h4>{{t('title')}}</h4>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="role-{{i}}" type="checkbox" attr.aria-label="Role {{role.data}}" class="form-check-input"
|
||||
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
|
||||
<label attr.for="role-{{i}}" class="form-check-label">{{role.data}}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="role-{{i}}" type="checkbox" class="form-check-input"
|
||||
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
|
||||
<label for="role-{{i}}" class="form-check-label">{{role.data}}</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { AccountService } from 'src/app/_services/account.service';
|
|||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||
import { NgFor } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-role-selector',
|
||||
|
@ -13,7 +14,7 @@ import { NgFor } from '@angular/common';
|
|||
styleUrls: ['./role-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgFor, ReactiveFormsModule, FormsModule]
|
||||
imports: [NgFor, ReactiveFormsModule, FormsModule, TranslocoModule]
|
||||
})
|
||||
export class RoleSelectorComponent implements OnInit {
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<ng-container *transloco="let t; read: 'all-series'">
|
||||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h2 title>
|
||||
{{title}}
|
||||
{{title}}
|
||||
</h2>
|
||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems | number}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
<h6 subtitle *ngIf="pagination">{{t('series-count', {num: pagination.totalItems | number})}}</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
|
@ -13,10 +14,12 @@
|
|||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</app-card-detail-layout>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -31,6 +31,7 @@ import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/car
|
|||
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import { NgIf, DecimalPipe } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
|
||||
|
||||
|
@ -40,7 +41,7 @@ import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-
|
|||
styleUrls: ['./all-series.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe]
|
||||
imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe, TranslocoModule]
|
||||
})
|
||||
export class AllSeriesComponent implements OnInit {
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<app-side-nav-companion-bar>
|
||||
<ng-container *transloco="let t; read: 'announcements'">
|
||||
<app-side-nav-companion-bar>
|
||||
<h2 title>
|
||||
Announcements
|
||||
{{t('title')}}
|
||||
</h2>
|
||||
</app-side-nav-companion-bar>
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-changelog></app-changelog>
|
||||
<app-changelog></app-changelog>
|
||||
</ng-container>
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { ChangelogComponent } from '../changelog/changelog.component';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-announcements',
|
||||
templateUrl: './announcements.component.html',
|
||||
styleUrls: ['./announcements.component.scss'],
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, ChangelogComponent]
|
||||
imports: [SideNavCompanionBarComponent, ChangelogComponent, TranslocoModule]
|
||||
})
|
||||
export class AnnouncementsComponent {
|
||||
|
||||
|
|
|
@ -1,24 +1,31 @@
|
|||
<div class="changelog">
|
||||
<p class="pb-2">If you do not see an <span class="badge bg-secondary">Installed</span> tag, you are on a nightly release. Only major versions will show as available.</p>
|
||||
<ng-container *ngFor="let update of updates; let indx = index;">
|
||||
<div class="card w-100 mb-2" style="width: 18rem;">
|
||||
<ng-container *transloco="let t; read: 'changelog'">
|
||||
<div class="changelog">
|
||||
<p class="pb-2">
|
||||
{{t('description', {installed: ''})}}
|
||||
<span class="badge bg-secondary">{{t('installed')}}</span>
|
||||
{{t('description-continued', {installed: ''})}}
|
||||
</p>
|
||||
<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}}
|
||||
<span class="badge bg-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
|
||||
<span class="badge bg-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
|
||||
<span class="badge bg-secondary" *ngIf="update.updateVersion === update.currentVersion">{{t('installed')}}</span>
|
||||
<span class="badge bg-secondary" *ngIf="update.updateVersion > update.currentVersion">{{t('available')}}</span>
|
||||
</h4>
|
||||
<h6 class="card-subtitle mb-1 mt-1 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
|
||||
<h6 class="card-subtitle mb-1 mt-1 text-muted">{{t('published-label')}}{{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 === update.currentVersion" href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">Installed</a>
|
||||
<a *ngIf="!update.isDocker && update.updateVersion !== update.currentVersion" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">Download</a>
|
||||
<a *ngIf="!update.isDocker && update.updateVersion === update.currentVersion" href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('installed')}}</a>
|
||||
<a *ngIf="!update.isDocker && update.updateVersion !== update.currentVersion" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('download')}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -4,13 +4,14 @@ import { ServerService } from 'src/app/_services/server.service';
|
|||
import { LoadingComponent } from '../../../shared/loading/loading.component';
|
||||
import { ReadMoreComponent } from '../../../shared/read-more/read-more.component';
|
||||
import { NgFor, NgIf, DatePipe } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-changelog',
|
||||
templateUrl: './changelog.component.html',
|
||||
styleUrls: ['./changelog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe]
|
||||
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe, TranslocoModule]
|
||||
})
|
||||
export class ChangelogComponent implements OnInit {
|
||||
|
||||
|
@ -25,8 +26,5 @@ export class ChangelogComponent implements OnInit {
|
|||
this.updates = updates;
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,11 @@ const routes: Routes = [
|
|||
},
|
||||
{
|
||||
path: 'libraries',
|
||||
loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule)
|
||||
loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
|
||||
},
|
||||
{
|
||||
path: 'libraries',
|
||||
loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
|
||||
},
|
||||
{
|
||||
path: 'want-to-read',
|
||||
|
@ -55,12 +59,12 @@ const routes: Routes = [
|
|||
canActivate: [AuthGuard, LibraryAccessGuard],
|
||||
children: [
|
||||
{
|
||||
path: ':libraryId',
|
||||
path: ':libraryId',
|
||||
pathMatch: 'full',
|
||||
loadChildren: () => import('../app/library-detail/library-detail.module').then(m => m.LibraryDetailModule)
|
||||
},
|
||||
{
|
||||
path: ':libraryId/series/:seriesId',
|
||||
path: ':libraryId/series/:seriesId',
|
||||
pathMatch: 'full',
|
||||
loadChildren: () => import('../app/series-detail/series-detail.module').then(m => m.SeriesDetailModule)
|
||||
},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, HostListener, Inject, OnInit } from '@angular/core';
|
||||
import {Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core';
|
||||
import { NavigationStart, Router, RouterOutlet } from '@angular/router';
|
||||
import {map, shareReplay, take} from 'rxjs/operators';
|
||||
import { AccountService } from './_services/account.service';
|
||||
|
@ -11,6 +11,8 @@ import { Observable } from 'rxjs';
|
|||
import {ThemeService} from "./_services/theme.service";
|
||||
import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.component';
|
||||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
@ -23,6 +25,9 @@ export class AppComponent implements OnInit {
|
|||
|
||||
transitionState$!: Observable<boolean>;
|
||||
|
||||
destroyRef = inject(DestroyRef);
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private accountService: AccountService, public navService: NavService,
|
||||
private libraryService: LibraryService,
|
||||
private router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
|
||||
|
@ -34,7 +39,7 @@ export class AppComponent implements OnInit {
|
|||
|
||||
// Close any open modals when a route change occurs
|
||||
router.events
|
||||
.pipe(filter(event => event instanceof NavigationStart))
|
||||
.pipe(filter(event => event instanceof NavigationStart), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((event) => {
|
||||
if (this.ngbModal.hasOpenModals()) {
|
||||
this.ngbModal.dismissAll();
|
||||
|
@ -44,7 +49,17 @@ export class AppComponent implements OnInit {
|
|||
this.transitionState$ = this.accountService.currentUser$.pipe(map((user) => {
|
||||
if (!user) return false;
|
||||
return user.preferences.noTransitions;
|
||||
}));
|
||||
}), takeUntilDestroyed(this.destroyRef));
|
||||
|
||||
// this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
// if (user && user.preferences.locale) {
|
||||
// this.translocoService.setActiveLang(user.preferences.locale);
|
||||
// } else {
|
||||
// // If no user or locale is available, fallback to the default language ('en')
|
||||
// const localStorageLocale = localStorage.getItem(accountService.localeKey) || 'en';
|
||||
// this.translocoService.setActiveLang(localStorageLocale);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
|
@ -66,7 +81,6 @@ export class AppComponent implements OnInit {
|
|||
|
||||
if (user) {
|
||||
// Bootstrap anything that's needed
|
||||
this.accountService.hasValidLicense().subscribe();
|
||||
this.themeService.getThemes().subscribe();
|
||||
this.libraryService.getLibraryNames().pipe(take(1), shareReplay()).subscribe();
|
||||
}
|
||||
|
|
|
@ -1,44 +1,46 @@
|
|||
<div class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None">
|
||||
<ng-container *transloco="let t; read: 'book-line-overlay'">
|
||||
<div class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None">
|
||||
|
||||
<div class="row g-0 justify-content-between">
|
||||
<ng-container [ngSwitch]="mode">
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
|
||||
<div class="row g-0 justify-content-between">
|
||||
<ng-container [ngSwitch]="mode">
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="copy()">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
<div>Copy</div>
|
||||
<div>{{t('copy')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||
<div>Bookmark</div>
|
||||
<div>{{t('bookmark')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="reset()">
|
||||
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
|
||||
<div>Close</div>
|
||||
<div>{{t('close')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
|
||||
<form [formGroup]="bookmarkForm">
|
||||
<div class="input-group">
|
||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" placeholder="Bookmark Name"
|
||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">Save</button>
|
||||
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
|
||||
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
|
||||
This field is required
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
|
||||
<form [formGroup]="bookmarkForm">
|
||||
<div class="input-group">
|
||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
|
||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
|
||||
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
|
||||
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {fromEvent, merge, of} from "rxjs";
|
||||
import {catchError, filter, tap} from "rxjs/operators";
|
||||
import {catchError} from "rxjs/operators";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import getBoundingClientRect from "@popperjs/core/lib/dom-utils/getBoundingClientRect";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {ReaderService} from "../../../_services/reader.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
enum BookLineOverlayMode {
|
||||
None = 0,
|
||||
|
@ -24,7 +24,7 @@ enum BookLineOverlayMode {
|
|||
@Component({
|
||||
selector: 'app-book-line-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslocoModule],
|
||||
templateUrl: './book-line-overlay.component.html',
|
||||
styleUrls: ['./book-line-overlay.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -132,7 +132,7 @@ export class BookLineOverlayComponent implements OnInit {
|
|||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
await navigator.clipboard.writeText(selection.toString());
|
||||
this.toastr.info('Copied to clipboard');
|
||||
this.toastr.info(translate('toasts.copied-to-clipboard'));
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
|
|
|
@ -1,155 +1,159 @@
|
|||
<div class="container-flex {{darkMode ? 'dark-mode' : ''}} reader-container {{ColumnLayout}} {{WritingStyleClass}}" tabindex="0" #reader>
|
||||
<ng-container *transloco="let t; read: 'book-reader'">
|
||||
<div class="container-flex {{darkMode ? 'dark-mode' : ''}} reader-container {{ColumnLayout}} {{WritingStyleClass}}" tabindex="0" #reader>
|
||||
<div class="fixed-top" #stickyTop>
|
||||
<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-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
|
||||
[libraryId]="libraryId"
|
||||
[volumeId]="volumeId"
|
||||
[chapterId]="chapterId"
|
||||
[seriesId]="seriesId"
|
||||
[pageNumber]="pageNum"
|
||||
(refreshToC)="refreshPersonalToC()">
|
||||
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-header')}}</a>
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
<app-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
|
||||
[libraryId]="libraryId"
|
||||
[volumeId]="volumeId"
|
||||
[chapterId]="chapterId"
|
||||
[seriesId]="seriesId"
|
||||
[pageNumber]="pageNum"
|
||||
(refreshToC)="refreshPersonalToC()">
|
||||
</app-book-line-overlay>
|
||||
<app-drawer #commentDrawer="drawer" [(isOpen)]="drawerOpen" [options]="{topOffset: topOffset}">
|
||||
<h5 header>
|
||||
Book Settings
|
||||
</h5>
|
||||
<div subheader>
|
||||
<div class="pagination-cont">
|
||||
<ng-container *ngIf="layoutMode !== BookPageLayoutMode.Default">
|
||||
<div class="virt-pagination-cont">
|
||||
<div class="g-0 text-center">
|
||||
Page
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between text-center row g-0" *ngIf="getVirtualPage() as vp" >
|
||||
<button class="btn btn-small btn-icon col-1" (click)="prevPage()" title="Prev Page">
|
||||
<i class="fa-solid fa-caret-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="col-1">{{vp[0]}}</div>
|
||||
<div class="col-8">
|
||||
<ngb-progressbar title="virtual pages" type="primary" height="5px" (click)="loadPage()" [value]="vp[0]" [max]="vp[1]"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="col-1 btn-icon" (click)="loadPage()" title="Go to last page">{{vp[1]}}</div>
|
||||
<button class="btn btn-small btn-icon col-1" (click)="nextPage()" title="Next Page"><i class="fa-solid fa-caret-right" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="g-0 text-center">
|
||||
Section
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between text-center row g-0">
|
||||
<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" (click)="goToPage(0)">{{pageNum}}</div>
|
||||
<div class="col-8">
|
||||
<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" (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>
|
||||
<app-drawer #commentDrawer="drawer" [(isOpen)]="drawerOpen" [options]="{topOffset: topOffset}">
|
||||
<h5 header>
|
||||
{{t('title')}}
|
||||
</h5>
|
||||
<div subheader>
|
||||
<div class="pagination-cont">
|
||||
<ng-container *ngIf="layoutMode !== BookPageLayoutMode.Default">
|
||||
<div class="virt-pagination-cont">
|
||||
<div class="g-0 text-center">
|
||||
{{t('page')}}
|
||||
</div>
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
<nav role="navigation">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="TabID.Settings">
|
||||
<a ngbNavLink>Settings</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-reader-settings
|
||||
(colorThemeUpdate)="updateColorTheme($event)"
|
||||
(styleUpdate)="updateReaderStyles($event)"
|
||||
(clickToPaginateChanged)="showPaginationOverlay($event)"
|
||||
(fullscreen)="toggleFullscreen()"
|
||||
(bookReaderWritingStyle)="updateWritingStyle($event)"
|
||||
(layoutModeUpdate)="updateLayoutMode($event)"
|
||||
(readingDirection)="updateReadingDirection($event)"
|
||||
(immersiveMode)="updateImmersiveMode($event)"
|
||||
></app-reader-settings>
|
||||
</ng-template>
|
||||
</li>
|
||||
<div class="d-flex align-items-center justify-content-between text-center row g-0" *ngIf="getVirtualPage() as vp" >
|
||||
<button class="btn btn-small btn-icon col-1" (click)="prevPage()" [title]="t('prev-page')">
|
||||
<i class="fa-solid fa-caret-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="col-1">{{vp[0]}}</div>
|
||||
<div class="col-8">
|
||||
<ngb-progressbar [title]="t('virtual-pages')" type="primary" height="5px" (click)="loadPage()" [value]="vp[0]" [max]="vp[1]"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="col-1 btn-icon" (click)="loadPage()" [title]="t('go-to-last-page')">{{vp[1]}}</div>
|
||||
<button class="btn btn-small btn-icon col-1" (click)="nextPage()" [title]="t('next-page')"><i class="fa-solid fa-caret-right" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>Table of Contents</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>ToC</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-table-of-contents [chapters]="chapters" [chapterId]="chapterId" [pageNum]="pageNum"
|
||||
[currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.PersonalTableOfContents">
|
||||
<a ngbNavLink>Bookmarks</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-personal-table-of-contents [chapterId]="chapterId" [pageNum]="pageNum" (loadChapter)="loadChapterPart($event)"
|
||||
[tocRefresh]="refreshPToC"></app-personal-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
<div class="g-0 text-center">
|
||||
{{t('pagination-header')}}
|
||||
</div>
|
||||
</app-drawer>
|
||||
<div class="d-flex align-items-center justify-content-between text-center row g-0">
|
||||
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" [title]="t('prev-chapter')"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||
<div class="col-1" (click)="goToPage(0)">{{pageNum}}</div>
|
||||
<div class="col-8">
|
||||
<ngb-progressbar style="cursor: pointer" [title]="t('go-to-page')" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="col-1 btn-icon" (click)="goToPage(maxPages - 1)" [title]="t('go-to-last-page')">{{maxPages - 1}}</div>
|
||||
<button class="btn btn-small btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter()" [title]="t('next-chapter')"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
<nav role="navigation">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="TabID.Settings">
|
||||
<a ngbNavLink>{{t('settings-header')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-reader-settings
|
||||
(colorThemeUpdate)="updateColorTheme($event)"
|
||||
(styleUpdate)="updateReaderStyles($event)"
|
||||
(clickToPaginateChanged)="showPaginationOverlay($event)"
|
||||
(fullscreen)="toggleFullscreen()"
|
||||
(bookReaderWritingStyle)="updateWritingStyle($event)"
|
||||
(layoutModeUpdate)="updateLayoutMode($event)"
|
||||
(readingDirection)="updateReadingDirection($event)"
|
||||
(immersiveMode)="updateImmersiveMode($event)"
|
||||
></app-reader-settings>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>{{t('table-of-contents-header')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="TabID.TableOfContents">
|
||||
<a ngbNavLink>{{t('toc-header')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-table-of-contents [chapters]="chapters" [chapterId]="chapterId" [pageNum]="pageNum"
|
||||
[currentPageAnchor]="currentPageAnchor" (loadChapter)="loadChapterPage($event)"></app-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.PersonalTableOfContents">
|
||||
<a ngbNavLink>{{t('bookmarks-header')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-personal-table-of-contents [chapterId]="chapterId" [pageNum]="pageNum" (loadChapter)="loadChapterPart($event)"
|
||||
[tocRefresh]="refreshPToC"></app-personal-table-of-contents>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
</app-drawer>
|
||||
</div>
|
||||
|
||||
<div #readingSection class="reading-section {{ColumnLayout}} {{WritingStyleClass}}" [ngStyle]="{'width': PageWidthForPagination}" [ngClass]="{'immersive' : immersiveMode || !actionBarVisible}" [@isLoading]="isLoading">
|
||||
|
||||
<ng-container *ngIf="clickToPaginate">
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" [ngClass]="{'immersive' : immersiveMode}"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
||||
[ngClass]="{'immersive' : immersiveMode}"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
</ng-container>
|
||||
<div #bookContainer class="book-container {{WritingStyleClass}}" [ngClass]="{'immersive' : immersiveMode}">
|
||||
<ng-container *ngIf="clickToPaginate">
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" [ngClass]="{'immersive' : immersiveMode}"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
||||
[ngClass]="{'immersive' : immersiveMode}"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
</ng-container>
|
||||
<div #bookContainer class="book-container {{WritingStyleClass}}" [ngClass]="{'immersive' : immersiveMode}">
|
||||
|
||||
<div #readingHtml class="book-content {{ColumnLayout}} {{WritingStyleClass}}"
|
||||
[ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}"
|
||||
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
||||
[innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
|
||||
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default) && !(writingStyle === WritingStyle.Vertical && layoutMode === BookPageLayoutMode.Default)"
|
||||
(click)="$event.stopPropagation();"
|
||||
[ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
</div>
|
||||
<div #readingHtml class="book-content {{ColumnLayout}} {{WritingStyleClass}}"
|
||||
[ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}"
|
||||
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
||||
[innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
|
||||
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default) && !(writingStyle === WritingStyle.Vertical && layoutMode === BookPageLayoutMode.Default)"
|
||||
(click)="$event.stopPropagation();"
|
||||
[ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
|
||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #actionBar>
|
||||
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
[disabled]="readingDirection === ReadingDirection.LeftToRight ? IsPrevDisabled : IsNextDisabled"
|
||||
title="{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} Page">
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}} {{readingDirection === ReadingDirection.RightToLeft ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||
</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>
|
||||
</button>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()">
|
||||
<i class="fa fa-bars" aria-hidden="true"></i></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="visually-hidden">Loading book...</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #showTitle>
|
||||
<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>
|
||||
<span class="book-title-text ms-1" [ngbTooltip]="bookTitle">{{bookTitle}}</span>
|
||||
</ng-template>
|
||||
title="{{readingDirection === ReadingDirection.LeftToRight ? t('previous') : t('next')}} Page">
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsPrevChapter : IsNextChapter) ? 'fa-angle-double-left' : 'fa-angle-left'}} {{readingDirection === ReadingDirection.RightToLeft ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" [title]="t('go-back')">
|
||||
<i class="fa fa-reply" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()">
|
||||
<i class="fa fa-bars" aria-hidden="true"></i></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="visually-hidden">{{t('loading-book')}}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||
[disabled]="readingDirection === ReadingDirection.LeftToRight ? IsNextDisabled : IsPrevDisabled"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)" title="{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} Page">
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}} {{readingDirection === ReadingDirection.LeftToRight ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-template #showTitle>
|
||||
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-mode-alt')">
|
||||
(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{t('incognito-mode-label')}}</span>)</span>
|
||||
<span class="book-title-text ms-1" [ngbTooltip]="bookTitle">{{bookTitle}}</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></button>
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||
[disabled]="readingDirection === ReadingDirection.LeftToRight ? IsNextDisabled : IsPrevDisabled"
|
||||
(click)="movePage(readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)" title="{{readingDirection === ReadingDirection.LeftToRight ? t('next') : t('previous')}} Page">
|
||||
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? IsNextChapter : IsPrevChapter) ? 'fa-angle-double-right' : 'fa-angle-right'}} {{readingDirection === ReadingDirection.LeftToRight ? 'next-page-highlight' : ''}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,42 +1,42 @@
|
|||
@font-face {
|
||||
font-family: "Fira_Sans";
|
||||
src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url(../../../../assets/fonts/Lato/Lato-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Libre_Baskerville";
|
||||
src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Merriweather";
|
||||
src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nanum_Gothic";
|
||||
src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "RocknRoll_One";
|
||||
src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype");
|
||||
src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "OpenDyslexic2";
|
||||
src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf) format("opentype");
|
||||
src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ import {
|
|||
PersonalTableOfContentsComponent,
|
||||
PersonalToCEvent
|
||||
} from "../personal-table-of-contents/personal-table-of-contents.component";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
|
||||
enum TabID {
|
||||
|
@ -101,7 +102,7 @@ const elementLevelStyles = ['line-height', 'font-family'];
|
|||
])
|
||||
],
|
||||
standalone: true,
|
||||
imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, BookLineOverlayComponent, PersonalTableOfContentsComponent]
|
||||
imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, BookLineOverlayComponent, PersonalTableOfContentsComponent, TranslocoModule]
|
||||
})
|
||||
export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
|
@ -577,7 +578,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
|
||||
if (!hasProgress) {
|
||||
this.toggleDrawer();
|
||||
this.toastr.info('You can modify book settings, save those settings for all books, and view table of contents from the drawer.');
|
||||
this.toastr.info(translate('toasts.book-settings-info'));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -782,12 +783,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
// Load chapter Id onto route but don't reload
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000});
|
||||
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.info(msg, '', {timeOut: 3000});
|
||||
this.cdRef.markForCheck();
|
||||
this.init();
|
||||
} else {
|
||||
// This will only happen if no actual chapter can be found
|
||||
this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase());
|
||||
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.warning(msg);
|
||||
this.isLoading = false;
|
||||
if (direction === 'Prev') {
|
||||
this.prevPageDisabled = true;
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
<div class="table-of-contents">
|
||||
<div *ngIf="Pages.length === 0">
|
||||
<em>Nothing Bookmarked yet</em>
|
||||
<ng-container *transloco="let t; read: 'personal-table-of-contents'">
|
||||
<div class="table-of-contents">
|
||||
<div *ngIf="Pages.length === 0">
|
||||
<em>{{t('no-data')}}}</em>
|
||||
</div>
|
||||
<ul>
|
||||
<li *ngFor="let page of Pages">
|
||||
<span (click)="loadChapterPage(page, '')">{{t('page', {value: page})}}</span>
|
||||
<ul class="chapter-title">
|
||||
<li class="ellipsis"
|
||||
[ngbTooltip]="bookmark.title"
|
||||
placement="right"
|
||||
*ngFor="let bookmark of bookmarks[page]" (click)="loadChapterPage(bookmark.pageNumber, bookmark.bookScrollId); $event.stopPropagation();">
|
||||
{{bookmark.title}}
|
||||
<button class="btn btn-icon ms-1" (click)="removeBookmark(bookmark); $event.stopPropagation();">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete', {bookmarkName: bookmark.title})}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul>
|
||||
<li *ngFor="let page of Pages">
|
||||
<span (click)="loadChapterPage(page, '')">Page {{page}}</span>
|
||||
<ul class="chapter-title">
|
||||
<li class="ellipsis"
|
||||
[ngbTooltip]="bookmark.title"
|
||||
placement="right"
|
||||
*ngFor="let bookmark of bookmarks[page]" (click)="loadChapterPage(bookmark.pageNumber, bookmark.bookScrollId); $event.stopPropagation();">
|
||||
{{bookmark.title}}
|
||||
<button class="btn btn-icon ms-1" (click)="removeBookmark(bookmark); $event.stopPropagation();">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Delete {{bookmark.title}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -13,6 +13,7 @@ import {ReaderService} from "../../../_services/reader.service";
|
|||
import {PersonalToC} from "../../../_models/readers/personal-toc";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
export interface PersonalToCEvent {
|
||||
pageNum: number;
|
||||
|
@ -22,7 +23,7 @@ export interface PersonalToCEvent {
|
|||
@Component({
|
||||
selector: 'app-personal-table-of-contents',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgbTooltip],
|
||||
imports: [CommonModule, NgbTooltip, TranslocoModule],
|
||||
templateUrl: './personal-table-of-contents.component.html',
|
||||
styleUrls: ['./personal-table-of-contents.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,169 +1,172 @@
|
|||
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
|
||||
<form [formGroup]="settingsForm">
|
||||
<ng-container *transloco="let t; read: 'reader-settings'">
|
||||
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
|
||||
<form [formGroup]="settingsForm">
|
||||
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
|
||||
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
|
||||
General Settings
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="control-container" >
|
||||
<div class="controls">
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">Font Family</label>
|
||||
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
||||
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 controls">
|
||||
<label for="fontsize" class="form-label col-6">Font Size</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
|
||||
{{t('general-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="control-container" >
|
||||
<div class="controls">
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
|
||||
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
||||
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 controls">
|
||||
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
|
||||
<input type="range" class="form-range ms-2 me-2" id="fontsize" min="50" max="300" step="10" formControlName="bookReaderFontSize" [ngbTooltip]="settingsForm.get('bookReaderFontSize')?.value + '%'">
|
||||
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 controls">
|
||||
<label for="linespacing" class="form-label col-6">Line Spacing</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<div class="row g-0 controls">
|
||||
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
1x
|
||||
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
|
||||
2.5x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 controls">
|
||||
<label for="margin" class="form-label col-6">Margin</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<div class="row g-0 controls">
|
||||
<label for="margin" class="form-label col-6">{{t('margin-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<i class="fa-solid fa-outdent"></i>
|
||||
<input type="range" class="form-range ms-2 me-2" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" [ngbTooltip]="settingsForm.get('bookReaderMargin')?.value + '%'">
|
||||
<i class="fa-solid fa-indent"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 justify-content-between mt-2">
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="row g-0 justify-content-between mt-2">
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">{{t('reset-to-defaults')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
|
||||
Reader Settings
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="readingdirection" class="form-label">Reading Direction</label>
|
||||
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
|
||||
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{readingDirectionModel === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
||||
<label for="writing-style" class="form-label">Writing Style <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
||||
<ng-template #writingStyleTooltip>Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.</ng-template>
|
||||
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
||||
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical'}}">
|
||||
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? 'Horizontal' : 'Vertical' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
|
||||
{{t('reader-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
|
||||
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
|
||||
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
||||
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
||||
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
||||
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
|
||||
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="tap-pagination" class="form-label">{{t('tap-to-paginate-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
|
||||
<ng-template #tapPaginationTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="tapPagination-help">
|
||||
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
|
||||
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="tap-pagination" class="form-label">Tap Pagination <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
|
||||
<ng-template #tapPaginationTooltip>Click the edges of the screen to paginate</ng-template>
|
||||
<span class="visually-hidden" id="tapPagination-help">
|
||||
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
|
||||
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? 'On' : 'Off'}} </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="immersive-mode" class="form-label">Immersive Mode <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
|
||||
<ng-template #immersiveModeTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
|
||||
<span class="visually-hidden" id="immersiveMode-help">
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="immersive-mode" class="form-label">{{t('immersive-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
|
||||
<ng-template #immersiveModeTooltip>{{t('immersive-mode-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="immersiveMode-help">
|
||||
<ng-container [ngTemplateOutlet]="immersiveModeTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
|
||||
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? 'On' : 'Off'}} </label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
|
||||
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
|
||||
</div>
|
||||
<!-- TODO: move this inline style into a class -->
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="fullscreen" class="form-label">Fullscreen <i class="fa fa-info-circle" aria-hidden="true" placement="top"
|
||||
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
|
||||
<ng-template #fullscreenTooltip>Put reader in fullscreen mode</ng-template>
|
||||
<span class="visually-hidden" id="fullscreen-help">
|
||||
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
||||
</span>
|
||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||
<span *ngIf="activeTheme?.isDarkTheme"> {{isFullscreen ? 'Exit' : 'Enter'}}</span>
|
||||
</div>
|
||||
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="fullscreen" class="form-label">{{t('fullscreen-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top"
|
||||
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
|
||||
<ng-template #fullscreenTooltip>{{t('fullscreen-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="fullscreen-help">
|
||||
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
||||
</span>
|
||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||
<i class="fa {{this.isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||
<span *ngIf="activeTheme?.isDarkTheme"> {{isFullscreen ? t('exit') : t('enter')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">{{t('layout-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
|
||||
<ng-template #layoutTooltip><span [innerHTML]="t('layout-mode-tooltip')"></span></ng-template>
|
||||
<span class="visually-hidden" id="layout-help">
|
||||
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
|
||||
</span>
|
||||
<br>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('layout-mode-label')">
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-scroll')}}</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-1col')}}</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col2">{{t('layout-mode-option-2col')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem id="color-panel" [title]="t('color-theme-title')" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
|
||||
{{t('color-theme-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls">
|
||||
<ng-container *ngFor="let theme of themes">
|
||||
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
|
||||
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
||||
{{t('theme.translationKey')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">Layout Mode <i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
|
||||
<ng-template #layoutTooltip>Scroll: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.</ng-template>
|
||||
<span class="visually-hidden" id="layout-help">
|
||||
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
|
||||
</span>
|
||||
<br>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">Scroll</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">1 Column</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col2">2 Column</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem id="color-panel" title="Color Theme" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
|
||||
Color Theme
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls">
|
||||
<ng-container *ngFor="let theme of themes">
|
||||
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
|
||||
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
||||
{{theme.name}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -26,6 +26,7 @@ import { BookWhiteTheme } from '../../_models/book-white-theme';
|
|||
import { BookPaperTheme } from '../../_models/book-paper-theme';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
/**
|
||||
* Used for book reader. Do not use for other components
|
||||
|
@ -46,7 +47,8 @@ export const bookColorThemes = [
|
|||
isDefault: true,
|
||||
provider: ThemeProvider.System,
|
||||
selector: 'brtheme-dark',
|
||||
content: BookDarkTheme
|
||||
content: BookDarkTheme,
|
||||
translationKey: 'theme-dark'
|
||||
},
|
||||
{
|
||||
name: 'Black',
|
||||
|
@ -55,7 +57,8 @@ export const bookColorThemes = [
|
|||
isDefault: false,
|
||||
provider: ThemeProvider.System,
|
||||
selector: 'brtheme-black',
|
||||
content: BookBlackTheme
|
||||
content: BookBlackTheme,
|
||||
translationKey: 'theme-black'
|
||||
},
|
||||
{
|
||||
name: 'White',
|
||||
|
@ -64,7 +67,8 @@ export const bookColorThemes = [
|
|||
isDefault: false,
|
||||
provider: ThemeProvider.System,
|
||||
selector: 'brtheme-white',
|
||||
content: BookWhiteTheme
|
||||
content: BookWhiteTheme,
|
||||
translationKey: 'theme-white'
|
||||
},
|
||||
{
|
||||
name: 'Paper',
|
||||
|
@ -73,7 +77,8 @@ export const bookColorThemes = [
|
|||
isDefault: false,
|
||||
provider: ThemeProvider.System,
|
||||
selector: 'brtheme-paper',
|
||||
content: BookPaperTheme
|
||||
content: BookPaperTheme,
|
||||
translationKey: 'theme-paper'
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -85,7 +90,7 @@ const mobileBreakpointMarginOverride = 700;
|
|||
styleUrls: ['./reader-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe]
|
||||
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe, TranslocoModule]
|
||||
})
|
||||
export class ReaderSettingsComponent implements OnInit {
|
||||
/**
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
<div class="table-of-contents">
|
||||
<ng-container *transloco="let t; read: 'table-of-contents'">
|
||||
<div class="table-of-contents">
|
||||
<div *ngIf="chapters.length === 0">
|
||||
<em>This book does not have Table of Contents set in the metadata or a toc file</em>
|
||||
<em>{{t('no-data')}}}</em>
|
||||
</div>
|
||||
<div *ngIf="chapters.length === 1; else nestedChildren">
|
||||
<ul>
|
||||
<li *ngFor="let chapter of chapters[0].children">
|
||||
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li *ngFor="let chapter of chapters[0].children">
|
||||
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ng-template #nestedChildren>
|
||||
<ul *ngFor="let chapterGroup of chapters" class="chapter-title">
|
||||
<li class="{{chapterGroup.page === pageNum ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
|
||||
{{chapterGroup.title}}
|
||||
</li>
|
||||
<ul *ngFor="let chapter of chapterGroup.children">
|
||||
<li class="{{cleanIdSelector(chapter.part) === currentPageAnchor ? 'active' : ''}}">
|
||||
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul *ngFor="let chapterGroup of chapters" class="chapter-title">
|
||||
<li class="{{chapterGroup.page === pageNum ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
|
||||
{{chapterGroup.title}}
|
||||
</li>
|
||||
<ul *ngFor="let chapter of chapterGroup.children">
|
||||
<li class="{{cleanIdSelector(chapter.part) === currentPageAnchor ? 'active' : ''}}">
|
||||
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { BookChapterItem } from '../../_models/book-chapter-item';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-table-of-contents',
|
||||
|
@ -8,7 +9,7 @@ import { NgIf, NgFor } from '@angular/common';
|
|||
styleUrls: ['./table-of-contents.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgFor]
|
||||
imports: [NgIf, NgFor, TranslocoModule]
|
||||
})
|
||||
export class TableOfContentsComponent {
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<ng-container *transloco="let t; read: 'bookmarks'">
|
||||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h2 title>
|
||||
Bookmarks
|
||||
{{t('title')}}
|
||||
</h2>
|
||||
<h6 subtitle>{{series.length | number}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
<h6 subtitle>{{t('series-count', {num: series.length | number})}}</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingBookmarks"
|
||||
[items]="series"
|
||||
[filterSettings]="filterSettings"
|
||||
|
@ -13,16 +14,17 @@
|
|||
[refresh]="refresh"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" (reload)="loadBookmarks()" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||
[actions]="actions"
|
||||
[selected]="bulkSelectionService.isCardSelected('bookmark', position)" (selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
|
||||
></app-card-item>
|
||||
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||
[actions]="actions"
|
||||
[selected]="bulkSelectionService.isCardSelected('bookmark', position)" (selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
|
||||
></app-card-item>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
There are no bookmarks. Try creating <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/bookmarks" rel="noopener noreferrer" target="_blank">one <i class="fa fa-external-link-alt" aria-hidden="true"></i></a>.
|
||||
{{t('no-data')}} <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/bookmarks" rel="noopener noreferrer" target="_blank">{{t('no-data-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</app-card-detail-layout>
|
||||
</ng-container>
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take, Subject } from 'rxjs';
|
||||
import { take } from 'rxjs';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
|
@ -23,6 +31,7 @@ import { CardItemComponent } from '../../../cards/card-item/card-item.component'
|
|||
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
|
@ -30,9 +39,9 @@ import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-
|
|||
styleUrls: ['./bookmarks.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, CardItemComponent, DecimalPipe]
|
||||
imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoModule]
|
||||
})
|
||||
export class BookmarksComponent implements OnInit, OnDestroy {
|
||||
export class BookmarksComponent implements OnInit {
|
||||
|
||||
bookmarks: Array<PageBookmark> = [];
|
||||
series: Array<Series> = [];
|
||||
|
@ -53,7 +62,7 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
private onDestroy: Subject<void> = new Subject<void>();
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private readerService: ReaderService, private seriesService: SeriesService,
|
||||
private downloadService: DownloadService, private toastr: ToastrService,
|
||||
|
@ -81,10 +90,6 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
|
@ -130,12 +135,12 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) {
|
||||
if (!await this.confirmService.confirm(this.translocoService.translate('bookmarks.confirm-delete'))) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.readerService.clearMultipleBookmarks(seriesIds).subscribe(() => {
|
||||
this.toastr.success('Bookmarks have been removed');
|
||||
this.toastr.success(this.translocoService.translate('bookmarks.delete-success'));
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadBookmarks();
|
||||
});
|
||||
|
@ -183,7 +188,7 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
async clearBookmarks(series: Series) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for ' + series.name + '? This cannot be undone.')) {
|
||||
if (!await this.confirmService.confirm(this.translocoService.translate('bookmarks.confirm-single-delete', {seriesName: series.name}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -195,7 +200,7 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
this.series.splice(index, 1);
|
||||
}
|
||||
this.clearingSeries[series.id] = false;
|
||||
this.toastr.success(series.name + '\'s bookmarks have been removed');
|
||||
this.toastr.success(this.translocoService.translate('delete-single-success', {seriesName: series.name}));
|
||||
this.refresh.emit();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
|
|
@ -1,44 +1,45 @@
|
|||
<ng-container *transloco="let t; read: 'bulk-add-to-collection'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Add to Collection</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3" *ngIf="lists.length >= 5">
|
||||
<label for="filter" class="form-label">Filter</label>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3" *ngIf="lists.length >= 5">
|
||||
<label for="filter" class="form-label">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item clickable" tabindex="0" role="option" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
|
||||
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" title="Promoted"></i>
|
||||
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" [title]="t('promoted')"></i>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>
|
||||
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">{{t('no-data')}}</li>
|
||||
<li class="list-group-item" *ngIf="loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content: normal">
|
||||
<div style="width: 100%;">
|
||||
<div class="d-flex">
|
||||
<div class="col-9 col-lg-10">
|
||||
<label class="form-label visually-hidden" 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">
|
||||
<button type="submit" class="btn btn-primary" (click)="create()">Create</button>
|
||||
</div>
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="modal-footer" style="justify-content: normal">
|
||||
<div style="width: 100%;">
|
||||
<div class="d-flex">
|
||||
<div class="col-9 col-lg-10">
|
||||
<label class="form-label visually-hidden" for="add-rlist">{{t('collection-label')}}</label>
|
||||
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button type="submit" class="btn btn-primary" (click)="create()">{{t('create')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
@ -7,11 +18,12 @@ import { ReadingList } from 'src/app/_models/reading-list';
|
|||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {FilterPipe} from "../../../pipe/filter.pipe";
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-add-to-collection',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, FilterPipe, NgbModalModule],
|
||||
imports: [CommonModule, ReactiveFormsModule, FilterPipe, NgbModalModule, TranslocoModule],
|
||||
templateUrl: './bulk-add-to-collection.component.html',
|
||||
styleUrls: ['./bulk-add-to-collection.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
|
||||
|
@ -34,6 +46,8 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
|||
|
||||
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
|
||||
|
@ -69,7 +83,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
|||
create() {
|
||||
const tagName = this.listForm.value.title;
|
||||
this.collectionService.addByMultiple(0, this.seriesIds, tagName).subscribe(() => {
|
||||
this.toastr.success('Series added to ' + tagName + ' collection');
|
||||
this.toastr.success(this.translocoService.translate('toasts.series-added-to-collection', {collectionName: tagName}));
|
||||
this.modal.close();
|
||||
});
|
||||
}
|
||||
|
@ -78,7 +92,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
|
|||
if (this.seriesIds.length === 0) return;
|
||||
|
||||
this.collectionService.addByMultiple(tag.id, this.seriesIds, '').subscribe(() => {
|
||||
this.toastr.success('Series added to ' + tag.title + ' collection');
|
||||
this.toastr.success(this.translocoService.translate('toasts.series-added-to-collection', {collectionName: tag.title}));
|
||||
this.modal.close();
|
||||
});
|
||||
|
||||
|
|
|
@ -1,95 +1,98 @@
|
|||
<ng-container *transloco="let t; read: 'edit-collection-tags'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{tag.title}} Collection</h4>
|
||||
<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'}}">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title', {collectionName: tag.title})}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
|
||||
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="TabID.General">
|
||||
<a ngbNavLink>{{TabID.General}}</a>
|
||||
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="TabID.General">
|
||||
<a ngbNavLink>{{t(TabID.General)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<form [formGroup]="collectionTagForm">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<label for="library-name" class="form-label">Name</label>
|
||||
<input id="library-name" class="form-control" formControlName="title" type="text"
|
||||
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
|
||||
Name must be unique
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 ms-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
|
||||
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
|
||||
<label class="form-check-label me-1" for="tag-promoted">Promote</label>
|
||||
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #promotedTooltip>Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.</ng-template>
|
||||
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
|
||||
</div>
|
||||
</div>
|
||||
<form [formGroup]="collectionTagForm">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<label for="library-name" class="form-label">{{t('name-label')}}</label>
|
||||
<input id="library-name" class="form-control" formControlName="title" type="text"
|
||||
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
|
||||
{{t('name-validation')}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 ms-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
|
||||
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
|
||||
<label class="form-check-label me-1" for="tag-promoted">{{t('promote-label')}}</label>
|
||||
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #promotedTooltip>{{t('promote-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<div class="row g-0 mb-3">
|
||||
<label for="summary" class="form-label">{{t('summary-label')}}</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Series">
|
||||
<a ngbNavLink>{{TabID.Series}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<h6>Applies to Series</h6>
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let item of series; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
<li [ngbNavItem]="TabID.Series">
|
||||
<a ngbNavLink>{{t(TabID.Series)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<h6>{{t('series-title')}}</h6>
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let item of series; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{TabID.CoverImage}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)"
|
||||
(selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked"
|
||||
(resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)"
|
||||
(selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked"
|
||||
(resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</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-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" [disabled]="collectionTagForm.invalid" (click)="save()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
|
||||
<button type="button" class="btn btn-primary" [disabled]="collectionTagForm.invalid" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -34,18 +34,19 @@ import { UploadService } from 'src/app/_services/upload.service';
|
|||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
|
||||
enum TabID {
|
||||
General = 'General',
|
||||
CoverImage = 'Cover Image',
|
||||
Series = 'Series'
|
||||
General = 'general-tab',
|
||||
CoverImage = 'cover-image-tab',
|
||||
Series = 'series-tab'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-collection-tags',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip],
|
||||
imports: [CommonModule, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoModule],
|
||||
templateUrl: './edit-collection-tags.component.html',
|
||||
styleUrls: ['./edit-collection-tags.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -65,6 +66,7 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
imageUrls: Array<string> = [];
|
||||
selectedCover: string = '';
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
translocoService = inject(TranslocoService);
|
||||
|
||||
get hasSomeSelected() {
|
||||
return this.selections != null && this.selections.hasSomeSelected();
|
||||
|
@ -80,7 +82,7 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
|
||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService,
|
||||
private imageService: ImageService, private uploadService: UploadService,
|
||||
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
|
@ -170,7 +172,8 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
const tag = this.collectionTagForm.value;
|
||||
tag.id = this.tag.id;
|
||||
|
||||
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
|
||||
if (unselectedIds.length == this.series.length &&
|
||||
!await this.confirmService.confirm(this.translocoService.translate('toasts.no-series-collection-warning'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -185,7 +188,7 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||
|
||||
forkJoin(apis).subscribe(() => {
|
||||
this.modal.close({success: true, coverImageUpdated: selectedIndex > 0});
|
||||
this.toastr.success('Tag updated');
|
||||
this.toastr.success(this.translocoService.translate('toasts.collection-updated'));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,492 +1,493 @@
|
|||
<div class="modal-container" *ngIf="series !== undefined">
|
||||
<ng-container *transloco="let t; read: 'edit-series-modal'">
|
||||
<div class="modal-container" *ngIf="series !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">
|
||||
{{this.series.name}} Details</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
<h4 class="modal-title">
|
||||
{{t('title', {seriesName: this.series.name})}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('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;">
|
||||
<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[TabID.General]">
|
||||
<a ngbNavLink>{{tabs[TabID.General]}}</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">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" readonly
|
||||
[class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li [ngbNavItem]="tabs[TabID.General]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.General])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">{{t('name-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" readonly
|
||||
[class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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' : ''}}"
|
||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">{{t('sort-name-label')}}</label>
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">{{t('localized-name-label')}}</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="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 class="row g-0" *ngIf="metadata">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">{{t('summary-label')}}</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>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{tabs[TabID.Metadata]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Metadata])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<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 class="col-lg-4 col-md-12">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="release-year" class="form-label">Release Year</label>
|
||||
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
|
||||
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.pattern">
|
||||
This must be a valid year greater than 1000 and 4 characters long
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</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[TabID.People]">
|
||||
<a ngbNavLink>{{tabs[TabID.People]}}</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[TabID.WebLinks]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{tabs[TabID.WebLinks]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>Here you can add many different links to external services.</p>
|
||||
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="web-link--{{i}}" class="visually-hidden">Web Link</label>
|
||||
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary me-1" (click)="addWebLink()">
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Add Link</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="removeWebLink(i)">
|
||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Remove Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||
<a ngbNavLink>{{tabs[TabID.CoverImage]}}</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.
|
||||
</p>
|
||||
<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[TabID.Related]">
|
||||
<a ngbNavLink>{{tabs[TabID.Related]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[TabID.Info]">
|
||||
<a ngbNavLink>{{tabs[TabID.Info]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>Information</h4>
|
||||
<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>{{series.format | mangaFormat}}</app-tag-badge></div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6">Created: {{series.created | date:'shortDate'}}</div>
|
||||
<div class="col-md-6">Last Read: {{series.latestReadDate | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">{{t('collections-label')}}</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 class="col-lg-4 col-md-12">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="release-year" class="form-label">{{t('release-year-label')}}</label>
|
||||
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
|
||||
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear" maxlength="4" minlength="4" [class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.pattern">
|
||||
This must be a valid year greater than 1000 and 4 characters long
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-auto">Folder Path: {{series.folderPath | defaultValue}}</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
Max Items: {{metadata.maxCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Highest Count found across all ComicInfo in the Series" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
Total Items: {{metadata.totalCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Max Issue or Volume field from all ComicInfo in the series" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">Publication Status: {{metadata.publicationStatus | publicationStatus}}</div>
|
||||
<div class="col-md-6">Total Pages: {{series.pages}}</div>
|
||||
<div class="col-md-6">Size: {{size | bytes}}</div>
|
||||
</div>
|
||||
<h4>Volumes</h4>
|
||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
|
||||
<li class="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 g-0">
|
||||
<div class="col">
|
||||
Added: {{volume.created | date: 'short'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Last Modified: {{volume.lastModified | date: 'short'}}
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{volume.pages}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">{{t('genres-label')}}</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 #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
|
||||
<ul class="list-group mt-2">
|
||||
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Chapter: {{file.chapter}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">{{t('tags-label')}}</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">{{t('language-label')}}</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">{{t('age-rating-label')}}</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">{{t('publication-status-label')}}</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[TabID.People]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.People])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="writer" class="form-label">{{t('writer-label')}}</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">{{t('cover-artist-label')}}</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">{{t('publisher-label')}}</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">{{t('penciller-label')}}</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">{{t('letterer-label')}}</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">{{t('inker-label')}}</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">{{t('editor-label')}}</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">{{t('colorist-label')}}</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">{{t('character-label')}}</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">{{t('translator-label')}}</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[TabID.WebLinks]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{t(tabs[TabID.WebLinks])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>{{t('web-link-description')}}</p>
|
||||
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="web-link--{{i}}" class="visually-hidden">{{t('web-link-label')}}</label>
|
||||
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary me-1" (click)="addWebLink()">
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('add-link-alt')}}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="removeWebLink(i)">
|
||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-link-alt')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.CoverImage])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
{{t('cover-image-description')}}
|
||||
</p>
|
||||
<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[TabID.Related]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Related])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[TabID.Info]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Info])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>{{t('info-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6" *ngIf="libraryName">{{t('library-title')}} {{libraryName | sentenceCase}}</div>
|
||||
<div class="col-md-6">{{t('format-title')}} <app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge></div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6">{{t('created-title')}} {{series.created | date:'shortDate'}}</div>
|
||||
<div class="col-md-6">{{t('last-read-title')}} {{series.latestReadDate | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">{{t('last-added-title')}} {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">{{t('last-scanned-title')}} {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-auto">{{t('folder-path-title')}} {{series.folderPath | defaultValue}}</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
{{t('max-items-title')}} {{metadata.maxCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('highest-count-tooltip')" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{t('total-items-title')}} {{metadata.totalCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('max-issue-tooltip')" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">{{t('publication-status-title')}} {{metadata.publicationStatus | publicationStatus}}</div>
|
||||
<div class="col-md-6">{{t('total-pages-title')}} {{series.pages}}</div>
|
||||
<div class="col-md-6">{{t('size-title')}} {{size | bytes}}</div>
|
||||
</div>
|
||||
<h4>Volumes</h4>
|
||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
|
||||
<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">{{t('volume-num')}} {{volume.name}}</h5>
|
||||
<div>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
{{t('added-title')}} {{volume.created | date: 'short'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('last-modified-title')}} {{volume.lastModified | date: 'short'}}
|
||||
</div>
|
||||
</div>
|
||||
<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]">
|
||||
{{t('view-files')}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('pages-title')}} {{volume.pages}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
|
||||
<ul class="list-group mt-2">
|
||||
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
{{t('chapter-title')}} {{file.chapter}}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('pages-title')}} {{file.pages}}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('format-title')}} <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-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>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">Save</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ng-template #lock let-item="item" let-field="field">
|
||||
<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 class="visually-hidden">{{t('field-locked-alt')}}</span>
|
||||
</span>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavModule, NgbNavOutlet,
|
||||
NgbNavOutlet,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, Observable, of } from 'rxjs';
|
||||
|
@ -38,7 +38,7 @@ import { MetadataService } from 'src/app/_services/metadata.service';
|
|||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CommonModule, NgTemplateOutlet} from "@angular/common";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
|
||||
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
|
||||
import {EditSeriesRelationComponent} from "../../edit-series-relation/edit-series-relation.component";
|
||||
|
@ -51,6 +51,7 @@ import {PublicationStatusPipe} from "../../../pipe/publication-status.pipe";
|
|||
import {BytesPipe} from "../../../pipe/bytes.pipe";
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
|
@ -87,6 +88,7 @@ enum TabID {
|
|||
NgbCollapse,
|
||||
NgbNavOutlet,
|
||||
DefaultValuePipe,
|
||||
TranslocoModule,
|
||||
|
||||
],
|
||||
templateUrl: './edit-series-modal.component.html',
|
||||
|
@ -104,7 +106,7 @@ export class EditSeriesModalComponent implements OnInit {
|
|||
initSeries!: Series;
|
||||
|
||||
volumeCollapsed: any = {};
|
||||
tabs = ['General', 'Metadata', 'People', 'Web Links', 'Cover Image', 'Related', 'Info'];
|
||||
tabs = ['general-tab', 'metadata-tab', 'people-tab', 'web-links-tab', 'cover-image-tab', 'related-tab', 'info-tab'];
|
||||
active = this.tabs[0];
|
||||
editSeriesForm!: FormGroup;
|
||||
libraryName: string | undefined = undefined;
|
||||
|
|
|
@ -1,27 +1,29 @@
|
|||
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
|
||||
<ng-container *transloco="let t; read: 'bulk-operations'">
|
||||
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
|
||||
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
|
||||
<div class="d-flex justify-content-around align-items-center">
|
||||
|
||||
<div class="d-flex justify-content-around align-items-center">
|
||||
|
||||
<span class="highlight">
|
||||
<i class="fa fa-check me-1" aria-hidden="true"></i>
|
||||
{{selectionCount | number}} items selected
|
||||
{{t('items-selected',{num: selectionCount | number})}}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<button *ngIf="hasMarkAsUnread" class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" ngbTooltip="Mark as Unread" placement="bottom">
|
||||
<span>
|
||||
<button *ngIf="hasMarkAsUnread" class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" [ngbTooltip]="t('mark-as-unread')" placement="bottom">
|
||||
<i class="fa-regular fa-circle-check" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Mark as Unread</span>
|
||||
<span class="visually-hidden">{{t('mark-as-unread')}}</span>
|
||||
</button>
|
||||
<button *ngIf="hasMarkAsRead" class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" ngbTooltip="Mark as Read" placement="bottom">
|
||||
<button *ngIf="hasMarkAsRead" class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" [ngbTooltip]="t('mark-as-read')" placement="bottom">
|
||||
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Mark as Read</span>
|
||||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||
</button>
|
||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
</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> Deselect All</button>
|
||||
</div>
|
||||
</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 me-1" aria-hidden="true"></i>{{t('deselect-all')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
|
@ -5,15 +5,15 @@ import {
|
|||
DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe, CommonModule} from "@angular/common";
|
||||
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-operations',
|
||||
|
@ -21,7 +21,9 @@ import {CardActionablesComponent} from "../card-item/card-actionables/card-actio
|
|||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
CardActionablesComponent
|
||||
CardActionablesComponent,
|
||||
TranslocoModule,
|
||||
NgbTooltip
|
||||
],
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss'],
|
||||
|
|
|
@ -1,169 +1,165 @@
|
|||
<div class="offcanvas-header">
|
||||
<ng-container *transloco="let t; read: 'card-detail-drawer'">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
<span class="modal-title" id="modal-basic-title">
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="data" [seriesName]="parentName"></app-entity-title>
|
||||
</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body pb-3">
|
||||
<div class="offcanvas-body pb-3">
|
||||
<div class="d-flex">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="vertical" style="max-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[TabID.General]">
|
||||
<a ngbNavLink>General</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="container-fluid" style="overflow: auto">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="vertical" style="max-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[TabID.General]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.General].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="container-fluid" style="overflow: auto">
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="d-none d-md-block col-md-2 col-lg-1">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="coverImageUrl"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-lg-11">
|
||||
<ng-container *ngIf="summary.length > 0; else noSummary">
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</ng-container>
|
||||
<ng-template #noSummary>
|
||||
No Summary available.
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="d-none d-md-block col-md-2 col-lg-1">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="coverImageUrl"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-lg-11">
|
||||
<ng-container *ngIf="summary.length > 0; else noSummary">
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</ng-container>
|
||||
<ng-template #noSummary>
|
||||
{{t('no-summary')}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
|
||||
|
||||
|
||||
<!-- 2 rows to show some tags-->
|
||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('writers-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.writers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.writers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('genres-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.genres.length > 0; else noBadges">
|
||||
<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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('publishers-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.publishers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.publishers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('tags-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.tags.length > 0; else noBadges">
|
||||
<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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noBadges>
|
||||
{{t('not-defined')}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Metadata]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Metadata].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||
[showReset]="chapter.coverImageLocked"
|
||||
[showApplyButton]="true"
|
||||
(applyCover)="applyCoverImage($event)"
|
||||
(resetCover)="resetCoverImage()"
|
||||
>
|
||||
</app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Files].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]="imageService.getChapterCoverImage(chapter.id)"></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">{{t('unread') | uppercase}}</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>{{t('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">
|
||||
{{t('pages')}} {{file.pages | number:''}}
|
||||
</div>
|
||||
|
||||
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
|
||||
|
||||
|
||||
<!-- 2 rows to show some tags-->
|
||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>Authors/Writers</h6>
|
||||
<ng-container *ngIf="chapterMetadata.writers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.writers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>Genres</h6>
|
||||
<ng-container *ngIf="chapterMetadata.genres.length > 0; else noBadges">
|
||||
<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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>Publisher</h6>
|
||||
<ng-container *ngIf="chapterMetadata.publishers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.publishers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>Tags</h6>
|
||||
<ng-container *ngIf="chapterMetadata.tags.length > 0; else noBadges">
|
||||
<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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noBadges>
|
||||
Not defined
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Metadata]">
|
||||
<a ngbNavLink>{{tabs[TabID.Metadata].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{tabs[TabID.Cover].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||
[showReset]="chapter.coverImageLocked"
|
||||
[showApplyButton]="true"
|
||||
(applyCover)="applyCoverImage($event)"
|
||||
(resetCover)="resetCoverImage()"
|
||||
>
|
||||
</app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{tabs[TabID.Files].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]="imageService.getChapterCoverImage(chapter.id)"></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 | number:''}}
|
||||
</div>
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
Added:
|
||||
<!-- TODO: This data.created can be removed after v0.5.5 release -->
|
||||
<ng-container *ngIf="file.created === '0001-01-01T00:00:00'; else fileDate">
|
||||
{{data.created | date: 'short' | defaultDate}}
|
||||
</ng-container>
|
||||
<ng-template #fileDate>
|
||||
{{file.created | date: 'short' | defaultDate}}
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="col">
|
||||
Size: {{file.bytes | bytes}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{t('size')}} {{file.bytes | bytes}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -49,6 +49,7 @@ import {BytesPipe} from "../../pipe/bytes.pipe";
|
|||
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
|
@ -60,7 +61,7 @@ enum TabID {
|
|||
@Component({
|
||||
selector: 'app-card-detail-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent],
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoModule],
|
||||
templateUrl: './card-detail-drawer.component.html',
|
||||
styleUrls: ['./card-detail-drawer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -72,6 +73,8 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||
@Input() libraryId: number = 0;
|
||||
@Input({required: true}) data!: Volume | Chapter;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
/**
|
||||
* If this is a volume, this will be first chapter for said volume.
|
||||
|
@ -94,7 +97,12 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||
libraryType: LibraryType = LibraryType.Manga;
|
||||
|
||||
|
||||
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}];
|
||||
tabs = [
|
||||
{title: 'general-tab', disabled: false},
|
||||
{title: 'metadata-tab', disabled: false},
|
||||
{title: 'cover-tab', disabled: false},
|
||||
{title: 'info-tab', disabled: false}
|
||||
];
|
||||
active = this.tabs[0];
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
|
@ -201,7 +209,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||
|
||||
resetCoverImage() {
|
||||
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
|
||||
this.toastr.info('A job has been enqueued to regenerate the cover image');
|
||||
this.toastr.info(this.translocoService.translate('toasts.regen-cover'));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -254,7 +262,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||
|
||||
readChapter(chapter: Chapter, incognito: boolean = false) {
|
||||
if (chapter.pages === 0) {
|
||||
this.toastr.error('There are no pages. Kavita was not able to read this archive.');
|
||||
this.toastr.error(this.translocoService.translate('toasts.no-pages'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -265,7 +273,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||
|
||||
download(chapter: Chapter) {
|
||||
if (this.downloadInProgress) {
|
||||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
this.toastr.info(this.translocoService.translate('toasts.download-in-progress'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,60 +1,62 @@
|
|||
<ng-container *transloco="let t; read: 'card-detail-layout'">
|
||||
|
||||
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
|
||||
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
|
||||
<div class="col me-auto">
|
||||
<h2>
|
||||
<h2>
|
||||
<span *ngIf="actions.length > 0" class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span>
|
||||
<span *ngIf="header !== undefined && header.length > 0">
|
||||
<span *ngIf="header !== undefined && header.length > 0">
|
||||
{{header}}
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination !== undefined">{{pagination.totalItems}}</span>
|
||||
<span class="badge bg-primary rounded-pill" [attr.aria-label]="t('total-items', {count: pagination.totalItems})" *ngIf="pagination !== undefined">{{pagination.totalItems}}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
<div class="viewport-container" [ngClass]="{'empty': items.length === 0 && !isLoading}">
|
||||
</div>
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
<div class="viewport-container" [ngClass]="{'empty': items.length === 0 && !isLoading}">
|
||||
<div class="content-container">
|
||||
<div class="card-container mt-2 mb-2">
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
<div class="card-container mt-2 mb-2">
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="jumpBarKeysToRender.length >= 4 && items.length > 0 && scroll.viewPortInfo.maxScrollPosition > 0" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
|
||||
</div>
|
||||
<ng-template #cardTemplate>
|
||||
</div>
|
||||
<ng-template #cardTemplate>
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
|
||||
<div class="mx-auto" *ngIf="items.length === 0 && !isLoading" style="width: 200px;">
|
||||
<p>
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
<p>
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
<ng-template #jumpBar>
|
||||
<ng-template #jumpBar>
|
||||
<div class="jump-bar">
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||
<button class="btn btn-link" [ngClass]="{'disabled': hasCustomSort()}" (click)="scrollTo(jumpKey)" [ngbTooltip]="jumpKey.size + ' Series'" placement="left">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||
<button class="btn btn-link" [ngClass]="{'disabled': hasCustomSort()}" (click)="scrollTo(jumpKey)" [ngbTooltip]="jumpKey.size + ' Series'" placement="left">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
|
|
@ -34,11 +34,12 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
|
|||
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LoadingComponent, VirtualScrollerModule, CardActionablesComponent, NgbTooltip, MetadataFilterComponent],
|
||||
imports: [CommonModule, LoadingComponent, VirtualScrollerModule, CardActionablesComponent, NgbTooltip, MetadataFilterComponent, TranslocoModule],
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<div class="card {{selected ? 'selected-highlight' : ''}}">
|
||||
<ng-container *transloco="let t; read: 'card-item'">
|
||||
<div class="card {{selected ? 'selected-highlight' : ''}}">
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
<ng-container *ngIf="total > 0 || suppressArchiveWarning">
|
||||
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageUrl"></app-image>
|
||||
|
@ -16,12 +17,12 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="error-banner" *ngIf="total === 0 && !suppressArchiveWarning">
|
||||
Cannot Read
|
||||
{{t('cannot-read')}}
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="read === 0 && total > 0">
|
||||
<div class="badge-container">
|
||||
<div class="not-read-badge" ></div>
|
||||
<div class="not-read-badge"></div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
@ -62,4 +63,6 @@
|
|||
{{libraryName | sentenceCase}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -40,6 +40,7 @@ import {CardActionablesComponent} from "./card-actionables/card-actionables.comp
|
|||
import {SentenceCasePipe} from "../../pipe/sentence-case.pipe";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
|
@ -55,7 +56,8 @@ import {RouterLink} from "@angular/router";
|
|||
MangaFormatIconPipe,
|
||||
CardActionablesComponent,
|
||||
SentenceCasePipe,
|
||||
RouterLink
|
||||
RouterLink,
|
||||
TranslocoModule
|
||||
],
|
||||
templateUrl: './card-item.component.html',
|
||||
styleUrls: ['./card-item.component.scss'],
|
||||
|
|
|
@ -1,102 +1,102 @@
|
|||
<ng-container *ngIf="chapter !== undefined">
|
||||
<ng-container>
|
||||
<span *ngIf="chapter.writers.length === 0 && chapter.coverArtists.length === 0
|
||||
<ng-container *transloco="let t; read: 'chapter-metadata-detail'">
|
||||
<ng-container *ngIf="chapter !== undefined">
|
||||
<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
|
||||
{{t('no-data')}}
|
||||
</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 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>
|
||||
|
||||
|
||||
<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="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="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="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 class="row g-0">
|
||||
<div class="col-auto mt-2" *ngIf="chapter.writers && chapter.writers.length > 0">
|
||||
<h6>{{t('writers-title')}}</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>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<div class="col-auto mt-2" *ngIf="chapter.coverArtists && chapter.coverArtists.length > 0">
|
||||
<h6>{{t('cover-artists-title')}}</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>{{t('pencillers-title')}}</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>{{t('inkers-title')}}</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>{{t('colorists-title')}}</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>
|
||||
|
||||
|
||||
<div class="col-auto mt-2" *ngIf="chapter.letterers && chapter.letterers.length > 0">
|
||||
<h6>{{t('letterers-title')}}</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="col-auto mt-2" *ngIf="chapter.editors && chapter.editors.length > 0">
|
||||
<h6>{{t('editors-title')}}</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="col-auto mt-2" *ngIf="chapter.publishers && chapter.publishers.length > 0">
|
||||
<h6>{{t('publishers-title')}}</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="col-auto mt-2" *ngIf="chapter.characters && chapter.characters.length > 0">
|
||||
<h6>{{t('characters-title')}}</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>{{t('translators-title')}}</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>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
|
@ -3,17 +3,16 @@ import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata';
|
|||
import {CommonModule} from "@angular/common";
|
||||
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-metadata-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, BadgeExpanderComponent, PersonBadgeComponent],
|
||||
imports: [CommonModule, BadgeExpanderComponent, PersonBadgeComponent, TranslocoModule],
|
||||
templateUrl: './chapter-metadata-detail.component.html',
|
||||
styleUrls: ['./chapter-metadata-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChapterMetadataDetailComponent {
|
||||
@Input() chapter: ChapterMetadata | undefined;
|
||||
|
||||
constructor() { }
|
||||
}
|
||||
|
|
|
@ -1,77 +1,80 @@
|
|||
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
|
||||
<ng-container *transloco="let t; read: 'cover-image-chooser'">
|
||||
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
|
||||
<form [formGroup]="form">
|
||||
<ngx-file-drop (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" [accept]="acceptableExtensions" [directory]="false"
|
||||
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row g-0 mt-3 pb-3" *ngIf="mode === 'all'">
|
||||
<div class="mx-auto">
|
||||
<div class="row g-0 mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ngx-file-drop (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" [accept]="acceptableExtensions" [directory]="false"
|
||||
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row g-0 mt-3 pb-3" *ngIf="mode === 'all'">
|
||||
<div class="mx-auto">
|
||||
<div class="row g-0 mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<a style="padding-right:0px" href="javascript:void(0)" (click)="changeMode('url')"><span class="phone-hidden">Enter a </span>Url</a>
|
||||
<span class="ps-1 pe-1">•</span>
|
||||
<span style="padding-right:0px" href="javascript:void(0)">Drag and drop</span>
|
||||
<span class="ps-1 pe-1" style="padding-right:0px">•</span>
|
||||
<a style="padding-right:0px" href="javascript:void(0)" (click)="openFileSelector()">Upload<span class="phone-hidden"> an image</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<a style="padding-right:0px" href="javascript:void(0)" (click)="changeMode('url')"><span class="phone-hidden">Enter a </span>Url</a>
|
||||
<span class="ps-1 pe-1">•</span>
|
||||
<span style="padding-right:0px" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
|
||||
<span class="ps-1 pe-1" style="padding-right:0px">•</span>
|
||||
<a style="padding-right:0px" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="mode === 'url'">
|
||||
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
|
||||
<div class="input-group col-auto me-md-2" style="width: 83%">
|
||||
<label class="input-group-text" for="load-image">Url</label>
|
||||
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="mode = 'all'" aria-label="Back">
|
||||
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>
|
||||
<span class="phone-hidden">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
|
||||
<ng-template>
|
||||
<ng-container *ngIf="mode === 'url'">
|
||||
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
|
||||
<div class="input-group col-auto me-md-2" style="width: 83%">
|
||||
<label class="input-group-text" for="load-image">{{t('url-label')}}</label>
|
||||
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
|
||||
{{t('load')}}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="mode = 'all'">
|
||||
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>
|
||||
<span class="phone-hidden">{{t('back')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
|
||||
<ng-template>
|
||||
|
||||
</ng-template>
|
||||
</form>
|
||||
|
||||
<div class="row g-0 chooser" style="padding-top: 10px">
|
||||
<div class="image-card col-auto"
|
||||
*ngIf="showReset" tabindex="0" aria-label="Reset cover image" (click)="reset()"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
|
||||
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="image-card col-auto"
|
||||
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
|
||||
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url" [processEvents]="idx > 0"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button class="btn btn-primary" style="width: 100%;" aria-label="Apply for uploaded image"
|
||||
(click)="applyImage(idx)">
|
||||
{{appliedIndex === idx ? 'Applied' : 'Apply'}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="image-card col-auto"
|
||||
*ngIf="showReset" tabindex="0" (click)="reset()"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
|
||||
<app-image class="card-img-top" [title]="t('reset-cover-tooltip')" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button style="width: 100%;" class="btn btn-secondary" (click)="resetImage()">{{t('reset')}}</button>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="image-card col-auto"
|
||||
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" [attr.aria-label]="t('image-num', {num: idx + 1})" (click)="selectImage(idx)"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
|
||||
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url" [processEvents]="idx > 0"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button class="btn btn-primary" style="width: 100%;"
|
||||
(click)="applyImage(idx)">
|
||||
{{appliedIndex === idx ? t('applied') : t('apply')}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue