Compare commits

...

204 Commits

Author SHA1 Message Date
advplyr d2e012d7b1 Version bump 2.0.16 2022-05-24 19:19:16 -05:00
advplyr d4fe0be386 Merge pull request #631 from cassieesposito/getBookDataFromDir-refactor
Get book data from dir refactor
2022-05-24 19:17:42 -05:00
Cassie Esposito 6d947bbc29 Converted indentation from 4 spaces to 2 2022-05-24 17:06:44 -07:00
advplyr 5187d0e55f Add:Option to hard delete podcast episode from file system #488 2022-05-24 18:38:25 -05:00
Cassie Esposito c6253e4fd4 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 16:26:59 -07:00
Cassie Esposito 1ab933c8b0 Refactored getSequence. Slight behavior changes introduced.
All components of the bottom level directory
except volume which can no longer use '-' for separation, but 'Vol 4 Title' is still valid
and '4. Title' or 'Vol 4.' are also now valid.
2022-05-24 16:24:10 -07:00
Cassie Esposito e2e5dd372a Merge branch 'master' into getBookDataFromDir-refactor 2022-05-24 12:56:10 -07:00
advplyr 3e98b6f749 Update:Remove manual sorting of podcast episodes and default to sort by published at 2022-05-23 19:28:00 -05:00
advplyr 3c465994fe Fix:Hide remove icon from author images with no image 2022-05-23 19:12:40 -05:00
advplyr 6cfe583535 Fix:Static router for downloading single file library items #627 2022-05-23 18:31:11 -05:00
advplyr 0ad7a98fc7 Add:Support for single book files to be detected by Watcher #610, Fix:Single media file in library folder root is only supported for books not podcasts 2022-05-23 18:15:15 -05:00
advplyr a8d5b543d7 Update:Parsing sequence from folder will strip leading zeros #562 2022-05-22 19:17:21 -05:00
advplyr f2e16017f6 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-22 08:14:42 -05:00
advplyr 4d227cbade Add copy to clipboard fallback 2022-05-22 08:05:39 -05:00
advplyr 15a85299b9 Merge pull request #612 from cassieesposito/scan-for-narrator
Scan for narrator
2022-05-21 12:04:06 -05:00
advplyr d22e9e32ed Remove dev dependency from package.json 2022-05-21 11:36:08 -05:00
advplyr 8beac53f5f Update:Send source back with auth request 2022-05-21 11:21:03 -05:00
Cassie Esposito cbad435690 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-21 08:07:42 -07:00
Cassie Esposito 169b637720 Removed dependency erroniously added by IDE 2022-05-21 08:06:06 -07:00
advplyr f083d4b5f6 Update dockerfile failing with dev dependency 2022-05-20 18:15:54 -05:00
Cassie Esposito 3451a312e9 Merge branch 'advplyr:master' into getBookDataFromDir-refactor 2022-05-20 15:45:10 -07:00
Cassie Esposito 927c1a3514 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-20 15:40:26 -07:00
advplyr dabcad5ebd Update experimental e-reader alert 2022-05-20 16:47:00 -05:00
advplyr 796602d1b2 Add:Enable e-reader server setting to allow all users to access experimental e-reader #614 2022-05-20 16:34:51 -05:00
advplyr 302870a101 Fix:Continue series shelf show next book in series #608 2022-05-20 15:55:03 -05:00
advplyr 3954aa1963 Merge pull request #611 from cassieesposito/npm-watch
Added support for npm run watch
2022-05-20 09:31:37 -05:00
Cassie Esposito 2d8c840ad6 Cleaned up function getSequence, became more forgiving of whitespace around metadata elements 2022-05-20 01:03:36 -07:00
Cassie Esposito f1f02b185e Cleaned function getPublishedYear 2022-05-19 22:55:00 -07:00
Cassie Esposito 13d21e90f8 Cleaned function getSubtitle 2022-05-19 22:31:55 -07:00
Cassie Esposito dd664da871 Separated individual element parsing functions out of function getBookDataFromDir 2022-05-19 22:10:53 -07:00
Cassie Esposito 6ff66370fe Use {} instead of [] for narrators tag. Removed logging left over from debugging. 2022-05-19 21:07:04 -07:00
Cassie Esposito 23904d57ad Narrator data is sucessfully saved from folder name. 2022-05-19 20:59:59 -07:00
Cassie Esposito efdb43e2d2 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-19 19:45:35 -07:00
Cassie Esposito 67523095d6 Narrators successfully isolated from path. Next steps: parse multiple narrators and save to disk 2022-05-19 19:42:45 -07:00
Cassie Esposito e2d869bb19 Added support for npm run watch to automatically reset server during development. 2022-05-19 19:10:51 -07:00
advplyr d38e9499db Version bump 2.0.15 2022-05-19 19:58:09 -05:00
advplyr c7429efe95 Update:Debian install script to create directories if they dont exist and set ownership #503 2022-05-19 19:51:27 -05:00
advplyr b925dbcc95 Fix:Updating library folder paths will only set file permissions if it needed to create the folder #529 2022-05-19 19:00:34 -05:00
advplyr 2a235b8324 Add:RSS feeds for audiobooks #606 2022-05-19 18:51:58 -05:00
advplyr 06cc2a1b21 Fix:Increase width of MoreMenu since Mark as Not Finished was wrapping 2022-05-19 18:11:02 -05:00
advplyr 4bcca97b1f Update:Home page continue series shelf to use first unplayed book (instead of next book after most recently played) #608 2022-05-19 18:09:26 -05:00
advplyr 313b9026f1 Add:Authors page links to filter by author books and link to series page #609 2022-05-19 17:42:34 -05:00
advplyr 139ee013a7 Add:Parsing tags from OPF metadata #602, Update:OPF parser to check for prefix on package/metadata/meta objects 2022-05-18 19:25:18 -05:00
advplyr 7e5ab477b2 Update:Persist scroll position for bookshelves #604 2022-05-18 18:37:38 -05:00
advplyr eba37c46cb Update:Confirmation when clicking force re-scan #591 2022-05-18 16:36:54 -05:00
advplyr 228d9cc301 Fix:Library scan toasts 2022-05-18 16:33:24 -05:00
advplyr 85946dd1d5 Update build-win add GZip 2022-05-17 19:57:50 -05:00
advplyr b40598593d Merge pull request #593 from BCNelson/master
Add devcontainer
2022-05-17 19:18:47 -05:00
advplyr e918a46d09 Fix:Siderail margin on mobile and tablets 2022-05-15 15:51:30 -05:00
advplyr 8061ee29d5 Add:Media session controls and metadata 2022-05-15 15:48:41 -05:00
Bradley Nelson e15e04f085 Add dev container 2022-05-15 14:24:24 -06:00
advplyr 958d68ffa9 Update readme remove default username and password 2022-05-15 13:47:53 -05:00
advplyr c8a743ccc1 Version bump 2.0.14 2022-05-15 12:53:41 -05:00
advplyr 09dc95f560 Fix:Create cache dirs on server init 2022-05-15 11:19:04 -05:00
advplyr 853858825b Fix:File permissions on cache dirs and cache images, Fix:Db delete read stream closing before write stream resulting in deletes sometimes not happening 2022-05-15 09:51:08 -05:00
advplyr c962090c3a Update:No longer creating initial root user and initial library, add init root user page, web app works with no libraries 2022-05-14 17:23:22 -05:00
advplyr 63a8e2433e Fix:Manage and chapters item page available on refresh 2022-05-14 13:08:56 -05:00
advplyr f78d287b59 Update:Matching authors uses the ASIN if set #572, Fix:Purge author image cache when updating author 2022-05-13 18:11:54 -05:00
advplyr eaa383b6d8 Update:Show siderail on all pages not just library pages 2022-05-13 17:40:43 -05:00
advplyr 113026ce13 Fix:Sanitize new podcast folder names and ensure feedUrl is in feed metadata #589 2022-05-13 17:13:58 -05:00
advplyr 578a946ca5 Fix:Update changes to filterdata (authors, narrators, genres, tags, languages, series) 2022-05-13 16:51:54 -05:00
advplyr f31306eda0 Fix:Realtime updates on book cards when changing series sequence #590 2022-05-13 16:26:34 -05:00
advplyr c62b716a2c Fix:Duplicate keys on episode slider 2022-05-13 10:39:33 -05:00
advplyr 97ed20c683 Merge pull request #586 from jflattery/main
Fix for unsorted feeds, update npm, add docker tag
2022-05-12 17:36:51 -05:00
jflattery d5c46dcbfb Dedupe packages 2022-05-12 21:14:36 +00:00
jflattery 30934edd57 Add tag
Addressing issues found on discord where users are not getting latest image
2022-05-12 21:01:47 +00:00
jflattery d06fd1a1b1 Update npm packages 2022-05-12 20:58:30 +00:00
jflattery 6bb36381f1 Fix for unsorted feeds
Fix for Podcasts such as Beers with Talos who don't publish their feed in a chronological order
2022-05-12 20:26:21 +00:00
advplyr a1331fb3f8 Version bump 2.0.13 2022-05-11 18:57:09 -05:00
advplyr 17d15144eb Update:Podcast new episode check cronjob to use last episode pub date if exists otherwise fallback to using last check date 2022-05-11 18:55:19 -05:00
advplyr 74d26eece4 Add:Re-broadcast podcast with RSS feed no longer experimental #553, Update:Podcast RSS feed modal warnings and note text 2022-05-11 18:34:17 -05:00
advplyr 474a7d08d0 Fix:Watcher & scanner on folder renames to check inode value and update existing library item paths 2022-05-11 18:18:54 -05:00
advplyr 639c930779 Fix:Remove all button when not viewing issues page #585 2022-05-11 17:39:15 -05:00
advplyr c6323f8ad9 Fix:Local playback session store date/dayOfWeek string to be used in stats 2022-05-11 17:35:04 -05:00
advplyr caea6c6371 Update:Show seconds in elapsedPretty 2022-05-11 17:20:32 -05:00
advplyr d285845e04 Fix:Crash when mobile sends invalid library item to sync with session 2022-05-11 17:07:41 -05:00
advplyr 5a6867e98a Add:Listening sessions calendar heat map 2022-05-11 16:27:40 -05:00
advplyr 621444114f Fix:Modal click outside srcElement null 2022-05-10 19:30:17 -05:00
advplyr 5591704aad Fix:Uploads page set folder path on load #581 2022-05-10 17:49:25 -05:00
advplyr cc1181b301 Add:Chapter editor, lookup chapters via audnexus, chapters table on audiobook landing page #435 2022-05-10 17:03:41 -05:00
advplyr 095f49824e Version bump 2.0.12 2022-05-09 18:45:33 -05:00
advplyr b330030f50 Fix:Ignore file metadata updates to metadata.abs files 2022-05-09 18:40:54 -05:00
advplyr a7d422e23f Add:Alternate view for home page, series and collections without wood texture #424 2022-05-09 18:23:23 -05:00
advplyr f51a31c8ca Update:Remove back arrow in appbar 2022-05-09 15:12:55 -05:00
advplyr 290340a385 Fix:Rescan filter out items not updated #577 2022-05-09 07:23:29 -05:00
advplyr 0137f6dfeb Update:Show publishedYear below book cards instead of author name 2022-05-08 18:54:41 -05:00
advplyr 7f27eabf3e Update:Authors page check user can access library items and can edit 2022-05-08 18:48:57 -05:00
advplyr 4f7588c87d Update:Author names to link to authors page 2022-05-08 18:43:24 -05:00
advplyr a19b6370c4 Fix:getBookCoverAspectRatio 2022-05-08 18:40:43 -05:00
advplyr fbd7ae10d1 Add:Authors landing page #187 2022-05-08 18:21:46 -05:00
advplyr f94c706fc8 Update:Item edit modal add Save and Save & Close buttons 2022-05-08 15:25:33 -05:00
advplyr 9de4b1069a Fix:Item edit modal scrollable and overflowing #574 2022-05-08 14:52:58 -05:00
advplyr 8fbe3c3884 Remove unnecessary background-image #569 2022-05-07 20:21:08 -05:00
advplyr abf9120363 Fix:Hide book library settings for podcast libraries #573 2022-05-07 20:10:23 -05:00
advplyr 69f250cba5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-07 20:01:33 -05:00
advplyr 2103edfcdc Update:Max levenshtein distance for matching author names to 3 #572 2022-05-07 20:01:29 -05:00
advplyr 02ba147bd4 Merge pull request #571 from jflattery/main
fix repo
2022-05-07 11:50:27 -05:00
jflattery 230b548921 fix repo 2022-05-07 16:09:59 +00:00
advplyr f34ebdc016 Version bump 2.0.11 2022-05-05 18:50:15 -05:00
advplyr 69ad651671 Fix:Context menu on library page 2022-05-05 18:12:27 -05:00
advplyr edc919b3f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-05 18:07:50 -05:00
advplyr c8c7a9ece5 Merge pull request #561 from jflattery/main
Add support for seasonal podcasts
2022-05-05 18:06:52 -05:00
advplyr 8702ac1ccf Fix:Manage tracks page 2022-05-05 18:04:17 -05:00
advplyr 33833e0a36 Update:Host fonts locally #563 2022-05-05 18:02:42 -05:00
jflattery 6b98baafdf Resolve @advplyr's feedback
Add 'itunes' tag to 'season' and fix display formating
2022-05-05 13:38:00 +00:00
jflattery cc285bb685 Add support for seasonal podcasts
Podcasts such as [Command Line Heroes](https://podcasts.apple.com/us/podcast/command-line-heroes/id1319947289) have multiple seasons in which each has it's own , . This seaks to add support for such podcast series.
2022-05-04 14:14:09 +00:00
advplyr ef0243f1d7 Version bump 2.0.10 2022-05-03 19:35:30 -05:00
advplyr 7a7d53f92e Update:Close author modal on update 2022-05-03 19:33:00 -05:00
advplyr 2e070227ab Update:Give full permissions to admin users except updating root or viewing root api token #137 2022-05-03 19:16:16 -05:00
advplyr 195a30096f Update:Experimental RSS feed setting custom slugs with default to library item id #553 2022-05-03 18:52:34 -05:00
advplyr 55c40658f2 Add:Sort by duration for audiobooks and sort by number of episodes for podcasts #558 2022-05-03 17:50:19 -05:00
advplyr db48a486e5 Fix:Drag and drop upload limits to 100 items per folder #560 2022-05-03 17:41:49 -05:00
advplyr d869a9836e Add:More menu for podcast episode cards with Mark as Finished and Edit Podcast #559 2022-05-03 17:21:22 -05:00
advplyr 55680cbc98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-03 16:30:54 -05:00
advplyr 9b7e6a6058 Fix:Linux build script to use node16 2022-05-03 16:30:49 -05:00
advplyr a482e5d316 Merge pull request #555 from selfhost-alt/handle-corrupted-backups
Handle corrupted backups gracefully and continue loading other backups
2022-05-03 06:48:08 -05:00
Selfhost Alt 5ac342defd Handle corrupted backups gracefully and continue loading other backups 2022-05-02 22:47:16 -07:00
advplyr 944a5b3e92 Version bump v2.0.9 2022-05-02 19:04:57 -05:00
advplyr 9b9de84740 Add:Experimental embed audio metadata page 2022-05-02 18:48:00 -05:00
advplyr 2746e61cb3 Fix:Authors card hide edit & search icon for users without edit permission #549 2022-05-02 17:32:52 -05:00
advplyr 7f1d797fb2 Update:Submit edit details closes modal 2022-05-02 17:31:02 -05:00
advplyr 2059c9f14a Fix:Podcast RSS feed require fs 2022-05-02 17:21:16 -05:00
advplyr 0e16a9c8de Update:Many more debug logs for auto-download podcasts, add timeout for feed request, use anonymous function in cron job 2022-05-02 17:17:26 -05:00
advplyr b6a33bf7bb Merge pull request #551 from jflattery/main
docker compose and run changes
2022-05-02 16:58:03 -05:00
jflattery ce88ac9f33 Revert "add version number"
This reverts commit d4cd8c6db9.
2022-05-02 21:48:28 +00:00
advplyr 678dceefed Add:Experimental generate podcast RSS feed #553 2022-05-02 16:42:30 -05:00
advplyr 8b38dda229 Add:experimental generate podcast feed for testing 2022-05-02 14:41:59 -05:00
advplyr 7373c7159b Add additional logs during podcast episode checks and allow up to 3 failed feed requests 2022-05-01 19:54:33 -05:00
advplyr e34a39dde4 Update:Edit modal merge tab to manage 2022-05-01 19:39:52 -05:00
jflattery d4cd8c6db9 add version number 2022-05-02 00:38:24 +00:00
jflattery 9e93a3c7e6 align docker compose with run 2022-05-02 00:09:47 +00:00
advplyr 4a8bcc90ea Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 18:33:51 -05:00
advplyr 84c12a6e7e Add:Experimental embed metadata in audio files #141 2022-05-01 18:33:46 -05:00
advplyr 2a513ac8b8 Merge pull request #550 from mediacowboy/master
Docker Compose Update Instructions
2022-05-01 16:37:48 -05:00
MediaCowboy 97687c96cd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:57:03 -05:00
MediaCowboy a42c13aec2 Docker Compose Update 2022-05-01 15:56:57 -05:00
advplyr 5f0f8b92d1 Fix:Continue series home page shelf to check for finished books in series #545 2022-05-01 15:31:07 -05:00
advplyr 78ca6aa679 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-01 15:12:26 -05:00
advplyr 22e3d4a150 Fix:Account tags accessible #542 2022-05-01 15:12:21 -05:00
advplyr e3fba1fb2b Merge pull request #548 from BeastleeUK/patch-1
Temp Fix for Unknown Error in App with Traefik
2022-05-01 13:46:20 -05:00
BeastleeUK 4d95250990 Temp Fix for Unknown Error in App with Traefik 2022-05-01 19:44:30 +01:00
advplyr 4776368501 Update docker-build.yml 2022-05-01 12:51:20 -05:00
advplyr 8b0ed2bf29 Update:readme ubuntu install section to point to website install docs 2022-05-01 12:40:28 -05:00
advplyr 54389e3c25 Version bump v2.0.8 2022-04-30 13:19:07 -05:00
advplyr bf0da1c6ec Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-30 12:33:54 -05:00
advplyr 591a866f8c Fix:Removing section from upload page #530 2022-04-30 12:31:58 -05:00
advplyr fc8473ed84 Add:Putting back in the Continue Series shelf on the home page #541 2022-04-30 12:24:48 -05:00
advplyr b19442e440 Remove old home page personalized API route 2022-04-30 11:36:05 -05:00
advplyr 7a51e0693d Merge pull request #534 from cassieesposito/tooltips_for_appbar
Added tooltips missing from Appbar buttons
2022-04-30 11:33:22 -05:00
Cassie Esposito 21785c8e72 Merge branch 'advplyr:master' into tooltips_for_appbar 2022-04-30 09:27:48 -07:00
Cassie Esposito bdf6ccbd2d Removed duplicate conditional from line 62 of client/components/app/Appbar.vue 2022-04-30 09:21:27 -07:00
advplyr ceb163570f Fix:Set next accessible library when currently selected library is removed 2022-04-29 18:57:46 -05:00
advplyr 049ae73d74 Update:Guest user accounts cannot change the account password #537 2022-04-29 18:38:13 -05:00
advplyr 729fdd5c9f Update:User type admin permissions to create podcasts and download episodes #507 2022-04-29 18:29:40 -05:00
advplyr 4dac8ac16c Fix:Account type select dropdown & add root user change password button 2022-04-29 18:19:04 -05:00
advplyr 220bbc3d2d Fix:Series covers on home page not spread out correctly #505, Update:Server settings are now returned with auth requests 2022-04-29 17:43:46 -05:00
advplyr c2a4b32192 Fix:Series on search page not directing to series page #533 2022-04-29 17:12:02 -05:00
advplyr 09d0d47549 Fix:Setting user can access all libraries/tags 2022-04-29 16:50:06 -05:00
advplyr 4185807da4 Add:Check for new episodes manual check and update last check time, Update:Adding new podcasts and downloading podcast episodes restricted to admin users 2022-04-29 16:42:40 -05:00
advplyr 8abda14e0f Version bump v2.0.7 2022-04-29 13:16:29 -05:00
advplyr 619e5c0895 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-29 13:14:19 -05:00
advplyr 3a2594cde9 Version bump v2.0.6 2022-04-29 13:13:54 -05:00
advplyr 5cca2d0155 Update docker-build.yml 2022-04-29 13:01:12 -05:00
advplyr a467637cb5 Version bump v2.0.5 2022-04-29 12:59:35 -05:00
advplyr 1a23001955 Update version check to use releases from gh api instead of tags, add 5 minute buffer between checking for new releases 2022-04-29 12:20:51 -05:00
advplyr 8942dca31d Update docker-build workflow 2022-04-29 09:48:00 -05:00
advplyr 2a919012b6 Version bump 2.0.4 2022-04-28 18:43:00 -05:00
advplyr 40b342498f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-28 18:40:34 -05:00
advplyr e220b2818a Add docker-build workflow 2022-04-28 18:40:29 -05:00
Cassie Esposito 620bf7990f Added tooltips for edit, delete, and deselect all buttons to client/components/app/Appbar.vue 2022-04-28 15:44:07 -07:00
advplyr 0df36d2609 Merge pull request #523 from mediacowboy/patch-1
Update readme.md
2022-04-28 17:43:50 -05:00
MediaCowboy adfe50a841 Update readme.md
Updated the pull command to reflect the new docker repo.
2022-04-27 22:26:44 -05:00
advplyr 35925ddc1b Merge pull request #522 from selfhost-alt/skip-matching-identified-media
Add options to skip matching media items if they already have an ASIN/ISBN
2022-04-27 20:14:04 -05:00
advplyr 33dfb764fa Add:Support for openaudible folder structure (subject to change), add support for treating single audio files in the root directory as library items #401 2022-04-27 19:42:34 -05:00
advplyr 49bef2c641 Fix:Uploader removing single item from parsed upload items #530 2022-04-27 18:08:07 -05:00
advplyr ac58536501 Fix:Drag n drop folder upload 2022-04-27 18:03:00 -05:00
advplyr c344555be3 Fix:default user settings for orderBy and default to sort ascending for titles and authors #515 2022-04-27 17:20:44 -05:00
MediaCowboy 645bcc53c6 Update readme.md
Removed the --rm from the docker install command and added Docker Update section
2022-04-26 21:28:24 -05:00
Selfhost Alt 84dd06dfc4 Add options to skip matching media items if they already have an ASIN/ISBN 2022-04-26 17:36:29 -07:00
advplyr 0a73dd6437 Add:Ability to ignore directories by putting a file named .ignore inside dir #516 2022-04-26 19:11:32 -05:00
advplyr 2cc055a1ad Fix:checkbox default check color add to tailwind safelist #521 2022-04-26 18:14:11 -05:00
advplyr d8ec3bd218 Merge pull request #512 from selfhost-alt/log-empty-folder-path-on-scan
Log full path when warning about empty root
2022-04-25 19:14:54 -05:00
advplyr d189ec74c9 Update item api endpoint to include user media progress with item if using query string include=progress and optionally episode=episodeid - for mobile app downloads 2022-04-25 19:03:26 -05:00
advplyr 4291769b93 Fix:Filter checks on server to check for mediaType 2022-04-25 17:36:18 -05:00
Selfhost Alt 22900a3f67 Log full path when warning about empty root 2022-04-25 15:28:03 -07:00
advplyr 7fa08449de Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-04-25 16:39:02 -05:00
advplyr 4f7203fccb Update docker template 2022-04-25 16:38:57 -05:00
advplyr 0eea766931 Merge pull request #509 from jflattery/patch-1
Change default to ghcr
2022-04-25 14:58:18 -05:00
advplyr 5c054aef90 Merge pull request #508 from jflattery/patch-2
Change default to ghcr
2022-04-25 14:57:54 -05:00
Jim Flattery a1674d5da1 Change default to ghcr 2022-04-25 15:45:08 -04:00
Jim Flattery 91597a5454 Change default to ghcr 2022-04-25 15:43:58 -04:00
advplyr 11354a3e3f Version bump 2.0.3 2022-04-24 19:18:43 -05:00
advplyr dcd4f69383 Fix: set downloaded/uploaded cover owner and permissions and if creating intitial config/metadata directories at startup then set owner of those #394 2022-04-24 19:12:00 -05:00
advplyr e253939c1e Fix: upload page files table selectable filename, size and type #406 2022-04-24 18:55:26 -05:00
advplyr f25ce1c0e7 Fix: overlapping text on collections book table #410 2022-04-24 18:51:11 -05:00
advplyr 7717e57c16 Fix: add extra check for valid names and valid author name #502 2022-04-24 18:41:47 -05:00
advplyr 2e28c9b06d Add: button on issues page to remove all library items with issues #476 2022-04-24 18:25:33 -05:00
advplyr 4bc7cd2045 Fix: show books with invalid audio files and add error icon on book items #491 2022-04-24 18:05:15 -05:00
advplyr 5389115120 Add: Button on series page to mark all series as finished #452 2022-04-24 17:46:21 -05:00
advplyr 6e99cf6570 Fix: filter sort authors and series, authors page sort alphabetical #497 2022-04-24 17:15:41 -05:00
advplyr 21bdd9f9ec Fix set invalid flag to false when adding first episode to an empty podcast library item, dont show podcast errors on episode cards 2022-04-24 17:03:43 -05:00
advplyr e3ae3f7e6a Update personalized api endpoint to new optimal function that only loops through library items once 2022-04-24 16:56:30 -05:00
advplyr 74bf917150 Update readme 2022-04-24 11:14:30 -05:00
advplyr 5666b263f5 Readme updates and banner update to represent podcasts 2022-04-24 11:11:49 -05:00
171 changed files with 7275 additions and 3716 deletions
+4
View File
@@ -0,0 +1,4 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
ENV NODE_ENV=development
+12
View File
@@ -0,0 +1,12 @@
{
"build": { "dockerfile": "Dockerfile" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
},
"extensions": [
"eamodio.gitlens"
]
}
+76
View File
@@ -0,0 +1,76 @@
---
name: Build and Push Docker Image
on:
push:
branches: [master]
tags:
- 'v*.*.*'
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- index.js
- package.json
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-20.04
steps:
- name: Check out
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
tags: |
type=edge,branch=master
type=semver,pattern={{version}}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Dockerhub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v2
with:
tags: ${{ steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+1 -1
View File
@@ -14,6 +14,6 @@ COPY index.js index.js
COPY package-lock.json package-lock.json COPY package-lock.json package-lock.json
COPY package.json package.json COPY package.json package.json
COPY server server COPY server server
RUN npm ci --production RUN npm ci --only=production
EXPOSE 80 EXPOSE 80
CMD ["npm", "start"] CMD ["npm", "start"]
+6 -47
View File
@@ -2,49 +2,11 @@
set -e set -e
set -o pipefail set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/" ABS_LOG_DIR="/var/log/audiobookshelf"
declare -r init_type='auto' declare -r init_type='auto'
declare -ri no_rebuild='0' declare -ri no_rebuild='0'
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
start_service () { start_service () {
: "${1:?'Service name was not defined'}" : "${1:?'Service name was not defined'}"
declare -r service_name="$1" declare -r service_name="$1"
@@ -76,13 +38,10 @@ start_service () {
fi fi
} }
# Create log directory if not there and set ownership
add_group 'audiobookshelf' '' if [ ! -d "$ABS_LOG_DIR" ]; then
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false' mkdir -p "$ABS_LOG_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
mkdir -p '/var/log/audiobookshelf' fi
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
start_service 'audiobookshelf' start_service 'audiobookshelf'
+58 -75
View File
@@ -2,13 +2,51 @@
set -e set -e
set -o pipefail set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/" FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks" DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf" CONFIG_PATH="/etc/default/audiobookshelf"
DEFAULT_PORT=7331 DEFAULT_PORT=7331
DEFAULT_HOST="0.0.0.0" DEFAULT_HOST="0.0.0.0"
CONFIG_PATH="/etc/default/audiobookshelf"
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
install_ffmpeg() { install_ffmpeg() {
echo "Starting FFMPEG Install" echo "Starting FFMPEG Install"
@@ -16,8 +54,9 @@ install_ffmpeg() {
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz" WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
if ! cd "$FFMPEG_INSTALL_DIR"; then if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2 echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
mkdir "$FFMPEG_INSTALL_DIR" mkdir "$FFMPEG_INSTALL_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
cd "$FFMPEG_INSTALL_DIR" cd "$FFMPEG_INSTALL_DIR"
fi fi
@@ -28,83 +67,23 @@ install_ffmpeg() {
echo "Good to go on Ffmpeg... hopefully" echo "Good to go on Ffmpeg... hopefully"
} }
should_build_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "You already have a config file. Do you want to use it?"
options=("Yes" "No")
select yn in "${options[@]}"
do
case $yn in
"Yes")
false; return
;;
"No")
true; return
;;
esac
done
else
echo "No existing config found in $CONFIG_PATH"
true; return
fi
}
setup_config_interactive() {
if should_build_config; then
echo "Okay, let's setup a new config."
AUDIOBOOK_PATH=""
read -p "
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
if [[ -z "$AUDIOBOOK_PATH" ]]; then
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
fi
DATA_PATH=""
read -p "
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
if [[ -z "$DATA_PATH" ]]; then
DATA_PATH="$DEFAULT_DATA_PATH"
fi
PORT=""
read -p "
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
if [[ -z "$PORT" ]]; then
PORT="$DEFAULT_PORT"
fi
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
METADATA_PATH=$DATA_PATH/metadata
CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$PORT
HOST=$DEFAULT_HOST"
echo "$config_text"
echo "$config_text" > /etc/default/audiobookshelf;
echo "Config created"
fi
}
setup_config() { setup_config() {
if [ -f "$CONFIG_PATH" ]; then if [ -f "$CONFIG_PATH" ]; then
echo "Existing config found." echo "Existing config found."
cat $CONFIG_PATH cat $CONFIG_PATH
else else
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
# Create directory and set permissions
echo "Creating default data dir at $DEFAULT_DATA_DIR"
mkdir "$DEFAULT_DATA_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
fi
echo "Creating default config." echo "Creating default config."
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
METADATA_PATH=$DEFAULT_DATA_PATH/metadata CONFIG_PATH=$DEFAULT_DATA_DIR/config
CONFIG_PATH=$DEFAULT_DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT PORT=$DEFAULT_PORT
@@ -118,6 +97,10 @@ setup_config() {
fi fi
} }
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
setup_config setup_config
install_ffmpeg install_ffmpeg
+1 -1
View File
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control; echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian # Package debian
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf . pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian fakeroot dpkg-deb --build dist/debian
+50 -24
View File
@@ -12,18 +12,30 @@
height: calc(100% - 64px); height: calc(100% - 64px);
max-height: calc(100% - 64px); max-height: calc(100% - 64px);
} }
.page.streaming { .page.streaming {
height: calc(100% - 64px - 165px); height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px); max-height: calc(100% - 64px - 165px);
} }
#bookshelf { #bookshelf {
height: calc(100% - 40px); height: calc(100% - 40px);
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f); background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
} }
.bookshelf-row {
/* Sidebar width + scrollbar width */
width: calc(100vw - 88px);
}
@media (max-width: 768px) { @media (max-width: 768px) {
#bookshelf { #bookshelf {
height: calc(100% - 80px); height: calc(100% - 80px);
} }
.bookshelf-row {
width: 100vw;
}
} }
#page-wrapper { #page-wrapper {
@@ -34,36 +46,25 @@
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
} }
::-webkit-scrollbar:horizontal { ::-webkit-scrollbar:horizontal {
height: 8px; height: 8px;
} }
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0); background-color: rgba(0, 0, 0, 0);
} }
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */ /* Handle */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #855620; background: #855620;
border-radius: 4px; border-radius: 4px;
} }
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */ /* Handle on hover */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #704922; background: #704922;
} }
.no-scroll::-webkit-scrollbar { .no-scroll::-webkit-scrollbar {
@@ -71,6 +72,13 @@
opacity: 0; opacity: 0;
} }
.no-scroll {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* Chrome, Safari, Edge, Opera */ /* Chrome, Safari, Edge, Opera */
.no-spinner::-webkit-outer-spin-button, .no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button { .no-spinner::-webkit-inner-spin-button {
@@ -89,18 +97,23 @@ input[type=number] {
width: 100%; width: 100%;
border: 1px solid #474747; border: 1px solid #474747;
} }
.tracksTable tr:nth-child(even) { .tracksTable tr:nth-child(even) {
background-color: #2e2e2e; background-color: #2e2e2e;
} }
.tracksTable tr { .tracksTable tr {
background-color: #373838; background-color: #373838;
} }
.tracksTable tr:hover { .tracksTable tr:hover {
background-color: #474747; background-color: #474747;
} }
.tracksTable td { .tracksTable td {
padding: 4px 8px; padding: 4px 8px;
} }
.tracksTable th { .tracksTable th {
padding: 4px 8px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
@@ -113,13 +126,22 @@ input[type=number] {
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 6px solid white; border-top: 6px solid white;
} }
.arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
}
.triangle-right { .triangle-right {
width: 0; width: 0;
height: 0; height: 0;
border-left: 8px solid transparent; border-left: 8px solid transparent;
border-bottom: 8px solid transparent; border-bottom: 8px solid transparent;
border-top: 8px solid rgb(34,127,35); border-top: 8px solid rgb(34, 127, 35);
border-right: 8px solid rgb(34,127,35); border-right: 8px solid rgb(34, 127, 35);
} }
.icon-text { .icon-text {
@@ -149,6 +171,7 @@ input[type=number] {
.box-shadow-book { .box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
} }
.shadow-height { .shadow-height {
height: calc(100% - 4px); height: calc(100% - 4px);
} }
@@ -165,9 +188,9 @@ input[type=number] {
Bookshelf Label Bookshelf Label
*/ */
.categoryPlacard { .categoryPlacard {
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
letter-spacing: 1px; letter-spacing: 1px;
} }
.shinyBlack { .shinyBlack {
background-color: #2d3436; background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%); background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
@@ -194,8 +217,11 @@ Bookshelf Label
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
line-height: 16px; /* fallback */ line-height: 16px;
max-height: 32px; /* fallback */ /* fallback */
-webkit-line-clamp: 2; /* number of lines to show */ max-height: 32px;
/* fallback */
-webkit-line-clamp: 2;
/* number of lines to show */
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
+275 -3
View File
@@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(/fonts/MaterialIcons.woff2) format('woff2'); src: url(/fonts/MaterialIcons.woff2) format('woff2');
} }
@font-face { @font-face {
font-family: 'Material Icons Outlined'; font-family: 'Material Icons Outlined';
font-style: normal; font-style: normal;
@@ -23,12 +23,13 @@
white-space: nowrap; white-space: nowrap;
word-wrap: normal; word-wrap: normal;
direction: ltr; direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) { .material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem; font-size: 1.5rem;
} }
.material-icons-outlined { .material-icons-outlined {
font-family: 'Material Icons Outlined'; font-family: 'Material Icons Outlined';
font-weight: normal; font-weight: normal;
@@ -40,9 +41,9 @@
white-space: nowrap; white-space: nowrap;
word-wrap: normal; word-wrap: normal;
direction: ltr; direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) { .material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -56,6 +57,7 @@
src: url(/fonts/GentiumBookBasic.woff2) format('woff2'); src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Gentium Book Basic'; font-family: 'Gentium Book Basic';
@@ -64,4 +66,274 @@
font-display: swap; font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2'); src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0370-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Ubuntu Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
-3
View File
@@ -153,9 +153,6 @@ export default {
}, },
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter ? this.currentChapter.title : ''
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
} }
}, },
methods: { methods: {
+23 -24
View File
@@ -2,15 +2,17 @@
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" /> <nuxt-link to="/">
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer"> <img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
<span class="material-icons text-4xl text-white">arrow_back</span> </nuxt-link>
</a>
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1> <nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link>
<ui-libraries-dropdown /> <ui-libraries-dropdown />
<controls-global-search class="hidden md:block" /> <controls-global-search v-if="currentLibrary" class="hidden md:block" />
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span> <span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
@@ -22,15 +24,15 @@
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span> <span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="Upload Media" role="button">upload</span> <span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="System Settings" role="button">settings</span> <span class="material-icons" aria-label="System Settings" role="button">settings</span>
</nuxt-link> </nuxt-link>
@@ -54,10 +56,16 @@
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<template v-if="userCanUpdate && numLibraryItemsSelected < 50"> <template v-if="userCanUpdate && numLibraryItemsSelected < 50">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> <ui-tooltip text="Edit" direction="bottom">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip>
</template> </template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> <ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span> <ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip text="Deselect All" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div> </div>
</div> </div>
</div> </div>
@@ -88,14 +96,11 @@ export default {
isHome() { isHome() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
showBack() {
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
},
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
@@ -145,12 +150,6 @@ export default {
toggleBookshelfTexture() { toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png') this.$store.dispatch('setBookshelfTexture', 'wood2.png')
}, },
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('setSelectedLibraryItems', [])
@@ -229,4 +228,4 @@ export default {
#appbar { #appbar {
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
</style> </style>
+42 -11
View File
@@ -1,15 +1,15 @@
<template> <template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative"> <div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget --> <!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture --> <!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40"> <div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div> <div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div> </div>
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
@@ -17,7 +17,25 @@
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center"> <div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl font-book py-4">No results for query</p> <p class="text-center text-xl font-book py-4">No results for query</p>
</div> </div>
<div v-else class="w-full flex flex-col items-center"> <!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-episode-slider>
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-series-slider>
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
</widgets-authors-slider>
</template>
</div>
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves"> <template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</template> </template>
@@ -44,8 +62,8 @@ export default {
} }
}, },
computed: { computed: {
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
@@ -56,6 +74,12 @@ export default {
libraryName() { libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName'] return this.$store.getters['libraries/getCurrentLibraryName']
}, },
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
},
isAlternativeBookshelfView() {
return this.bookshelfView === this.$constants.BookshelfView.TITLES
},
bookCoverWidth() { bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6 if (this.isCoverSquareAspectRatio) return coverSize * 1.6
@@ -91,7 +115,7 @@ export default {
}, },
async fetchCategories() { async fetchCategories() {
var categories = await this.$axios var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) .$get(`/api/libraries/${this.currentLibraryId}/personalized`)
.then((data) => { .then((data) => {
return data return data
}) })
@@ -128,8 +152,7 @@ export default {
type: 'series', type: 'series',
entities: this.results.series.map((seriesObj) => { entities: this.results.series.map((seriesObj) => {
return { return {
name: seriesObj.series.name, ...seriesObj.series,
series: seriesObj.series,
books: seriesObj.books, books: seriesObj.books,
type: 'series' type: 'series'
} }
@@ -167,7 +190,15 @@ export default {
}, },
settingsUpdated(settings) {}, settingsUpdated(settings) {},
scan() { scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId }) this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem) console.log('libraryItem added', libraryItem)
+10 -35
View File
@@ -1,15 +1,15 @@
<template> <template>
<div class="relative"> <div class="relative">
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled"> <div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
<div class="w-full h-full pt-6"> <div class="w-full h-full pt-6">
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center"> <div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities"> <template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" /> <cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'episode'" class="flex items-center"> <div v-if="shelf.type === 'episode'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities"> <template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" /> <cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'series'" class="flex items-center"> <div v-if="shelf.type === 'series'" class="flex items-center">
@@ -17,18 +17,9 @@
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" /> <cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'tags'" class="flex items-center">
<template v-for="entity in shelf.entities">
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
</nuxt-link>
</template>
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center"> <div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities"> <template v-for="entity in shelf.entities">
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`"> <cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
<cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</nuxt-link>
</template> </template>
</div> </div>
</div> </div>
@@ -48,7 +39,6 @@
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight"> <div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
<span class="material-icons text-6xl text-white">chevron_right</span> <span class="material-icons text-6xl text-white">chevron_right</span>
</div> </div>
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
</div> </div>
</template> </template>
@@ -70,9 +60,7 @@ export default {
canScrollLeft: false, canScrollLeft: false,
isScrolling: false, isScrolling: false,
scrollTimer: null, scrollTimer: null,
updateTimer: null, updateTimer: null
showAuthorModal: false,
selectedAuthor: null
} }
}, },
computed: { computed: {
@@ -98,13 +86,12 @@ export default {
this.updateSelectionMode(false) this.updateSelectionMode(false)
}, },
editAuthor(author) { editAuthor(author) {
this.selectedAuthor = author this.$store.commit('globals/showEditAuthorModal', author)
this.showAuthorModal = true
}, },
editBook(audiobook) { editItem(libraryItem) {
var bookIds = this.shelf.entities.map((e) => e.id) var itemIds = this.shelf.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds) this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', audiobook) this.$store.commit('showEditModal', libraryItem)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
@@ -197,25 +184,13 @@ export default {
<style> <style>
.categorizedBookshelfRow { .categorizedBookshelfRow {
scroll-behavior: smooth; scroll-behavior: smooth;
width: calc(100vw - 80px);
/* background-color: rgb(214, 116, 36); */
background-image: var(--bookshelf-texture-img); background-image: var(--bookshelf-texture-img);
/* background-position: center; */
/* background-size: contain; */
background-repeat: repeat-x; background-repeat: repeat-x;
} }
@media (max-width: 768px) {
.categorizedBookshelfRow {
width: 100vw;
}
}
.bookshelfDividerCategorized { .bookshelfDividerCategorized {
background: rgb(149, 119, 90); background: rgb(149, 119, 90);
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%); background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
box-shadow: 2px 14px 8px #111111aa; box-shadow: 2px 14px 8px #111111aa;
} }
+85 -4
View File
@@ -14,16 +14,28 @@
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome"> <template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p> <p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex"> <div v-else class="items-center hidden md:flex w-full">
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer"> <div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span> <span class="material-icons text-2xl text-white">west</span>
</div> </div>
<p class="pl-4 font-book text-lg"> <p class="pl-4 font-book text-lg">
{{ selectedSeries }} {{ seriesName }}
</p> </p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3"> <div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span> <span class="font-mono">{{ numShowing }}</span>
</div> </div>
<div class="flex-grow" />
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
<div class="h-5 w-5">
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
>
</div> </div>
<div class="flex-grow hidden sm:inline-block" /> <div class="flex-grow hidden sm:inline-block" />
@@ -38,6 +50,8 @@
<span class="material-icons" style="font-size: 1.4rem">view_list</span> <span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div> </div>
</div> --> </div> -->
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
</template> </template>
<template v-else-if="page === 'search'"> <template v-else-if="page === 'search'">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer"> <div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
@@ -56,7 +70,10 @@ export default {
props: { props: {
page: String, page: String,
isHome: Boolean, isHome: Boolean,
selectedSeries: String, selectedSeries: {
type: Object,
default: () => null
},
searchQuery: String, searchQuery: String,
viewMode: String viewMode: String
}, },
@@ -66,10 +83,15 @@ export default {
hasInit: false, hasInit: false,
totalEntities: 0, totalEntities: 0,
keywordFilter: null, keywordFilter: null,
keywordTimeout: null keywordTimeout: null,
processingSeries: false,
processingIssues: false
} }
}, },
computed: { computed: {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast' return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
}, },
@@ -103,9 +125,68 @@ export default {
}, },
showLibrary() { showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
},
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
} }
}, },
methods: { methods: {
removeAllIssues() {
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
this.processingIssues = true
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
this.processingIssues = false
})
}
},
markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
id: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Series update success')
this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false
})
.catch((error) => {
this.$toast.error('Series update failed')
console.error('Failed to batch update read/not read', error)
this.processingSeries = false
})
},
searchBackArrow() { searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`) this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
}, },
+28 -13
View File
@@ -9,9 +9,13 @@
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }"> <div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<p class="font-mono text-sm">v{{ $config.version }}</p> <div class="flex justify-between">
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a> <p class="font-mono text-sm">v{{ $config.version }}</p>
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div> </div>
</div> </div>
</template> </template>
@@ -25,11 +29,17 @@ export default {
return {} return {}
}, },
computed: { computed: {
userIsRoot() { Source() {
return this.$store.getters['user/getIsRoot'] return this.$store.state.Source
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}, },
configRoutes() { configRoutes() {
if (!this.userIsRoot) { if (!this.userIsAdminOrUp) {
return [ return [
{ {
id: 'config-stats', id: 'config-stats',
@@ -38,7 +48,7 @@ export default {
} }
] ]
} }
return [ const configRoutes = [
{ {
id: 'config', id: 'config',
title: 'Settings', title: 'Settings',
@@ -63,18 +73,23 @@ export default {
id: 'config-log', id: 'config-log',
title: 'Log', title: 'Log',
path: '/config/log' path: '/config/log'
}, }
{ ]
if (this.currentLibraryId) {
configRoutes.push({
id: 'config-library-stats', id: 'config-library-stats',
title: 'Library Stats', title: 'Library Stats',
path: '/config/library-stats' path: '/config/library-stats'
}, })
{ configRoutes.push({
id: 'config-stats', id: 'config-stats',
title: 'Your Stats', title: 'Your Stats',
path: '/config/stats' path: '/config/stats'
} })
] }
return configRoutes
}, },
wrapperClass() { wrapperClass() {
var classes = [] var classes = []
+41 -8
View File
@@ -6,9 +6,9 @@
</div> </div>
</template> </template>
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn> <ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
@@ -22,8 +22,9 @@
</div> </div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture --> <!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40"> <div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"> <div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p> <p class="text-sm py-0.5">Texture</p>
</div> </div>
@@ -42,6 +43,7 @@ export default {
mixins: [bookshelfCardsHelpers], mixins: [bookshelfCardsHelpers],
data() { data() {
return { return {
routeFullPath: null,
initialized: false, initialized: false,
bookshelfHeight: 0, bookshelfHeight: 0,
bookshelfWidth: 0, bookshelfWidth: 0,
@@ -79,8 +81,8 @@ export default {
} }
}, },
computed: { computed: {
isRootUser() { userIsAdminOrUp() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsAdminOrUp']
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
@@ -126,7 +128,7 @@ export default {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
}, },
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
if (!this.isEntityBook) return false // Only used for bookshelf showing books // if (!this.isEntityBook) return false // Only used for bookshelf showing books
return this.bookshelfView === this.$constants.BookshelfView.TITLES return this.bookshelfView === this.$constants.BookshelfView.TITLES
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
@@ -185,7 +187,10 @@ export default {
return 6 return 6
}, },
shelfHeight() { shelfHeight() {
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier if (this.isAlternativeBookshelfView) {
var extraTitleSpace = this.isEntityBook ? 80 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40 return this.entityHeight + 40
}, },
totalEntityCardWidth() { totalEntityCardWidth() {
@@ -409,6 +414,8 @@ export default {
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) { if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl) window.history.replaceState({ path: newurl }, '', newurl)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
return true return true
} }
@@ -526,6 +533,15 @@ export default {
await this.fetchEntites(0) await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntites(0, lastBookIndex)
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
if (path === this.routeFullPath) {
// Exact path match with query so use scroll position
window.bookshelf.scrollTop = scrollTop
}
}
}, },
executeRebuild() { executeRebuild() {
clearTimeout(this.resizeTimeout) clearTimeout(this.resizeTimeout)
@@ -601,13 +617,25 @@ export default {
} }
}, },
scan() { scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId }) this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
} }
}, },
mounted() { mounted() {
this.initListeners() this.initListeners()
this.routeFullPath = window.location.pathname + (window.location.search || '')
}, },
updated() { updated() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
setTimeout(() => { setTimeout(() => {
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) { if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth) console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
@@ -618,6 +646,11 @@ export default {
beforeDestroy() { beforeDestroy() {
this.destroyEntityComponents() this.destroyEntityComponents()
this.removeListeners() this.removeListeners()
// Set bookshelf scroll position for specific bookshelf page and query
if (window.bookshelf) {
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
}
} }
} }
</script> </script>
+18 -5
View File
@@ -1,6 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> <!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
@@ -52,7 +55,7 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<icons-podcast-svg class="w-6 h-6" /> <icons-podcast-svg class="w-6 h-6" />
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p> <p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
@@ -79,8 +82,15 @@ export default {
return {} return {}
}, },
computed: { computed: {
showExperimentalFeatures() { isShowingBookshelfToolbar() {
return this.$store.state.showExperimentalFeatures if (!this.$route.name) return false
return this.$route.name.startsWith('library')
},
offsetTop() {
return 64
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}, },
paramId() { paramId() {
return this.$route.params ? this.$route.params.id || '' : '' return this.$route.params ? this.$route.params.id || '' : ''
@@ -112,6 +122,9 @@ export default {
showLibrary() { showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
}, },
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
showingIssues() { showingIssues() {
if (!this.$route.query) return false if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues' return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
+67 -5
View File
@@ -12,7 +12,7 @@
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p> <p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base"> <p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p> <p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
</div> </div>
@@ -74,9 +74,6 @@ export default {
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['getServerSetting']('coverAspectRatio')
}, },
@@ -148,6 +145,7 @@ export default {
setPlaying(isPlaying) { setPlaying(isPlaying) {
this.isPlaying = isPlaying this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying) this.$store.commit('setIsPlaying', isPlaying)
this.updateMediaSessionPlaybackState()
}, },
setSleepTimer(seconds) { setSleepTimer(seconds) {
this.sleepTimerSet = true this.sleepTimerSet = true
@@ -240,6 +238,71 @@ export default {
this.playerHandler.closePlayer() this.playerHandler.closePlayer()
this.$store.commit('setMediaPlaying', null) this.$store.commit('setMediaPlaying', null)
}, },
mediaSessionPlay() {
console.log('Media session play')
this.playerHandler.play()
},
mediaSessionPause() {
console.log('Media session pause')
this.playerHandler.pause()
},
mediaSessionStop() {
console.log('Media session stop')
this.playerHandler.pause()
},
mediaSessionSeekBackward() {
console.log('Media session seek backward')
this.playerHandler.jumpBackward()
},
mediaSessionSeekForward() {
console.log('Media session seek forward')
this.playerHandler.jumpForward()
},
mediaSessionSeekTo(e) {
console.log('Media session seek to', e)
if (e.seekTime !== null && !isNaN(e.seekTime)) {
this.playerHandler.seek(e.seekTime)
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
}
},
setMediaSession() {
if (!this.streamLibraryItem) {
console.error('setMediaSession: No library item set')
return
}
if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
const artwork = [
{
src: coverImageSrc
}
]
navigator.mediaSession.metadata = new MediaMetadata({
title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '',
artwork
})
console.log('Set media session metadata', navigator.mediaSession.metadata)
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
// navigator.mediaSession.setActionHandler('previoustrack')
// navigator.mediaSession.setActionHandler('nexttrack')
} else {
console.warn('Media session not available')
}
},
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
var chunks = data.chunks var chunks = data.chunks
@@ -312,7 +375,6 @@ export default {
libraryItem, libraryItem,
episodeId episodeId
}) })
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate) this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
}, },
pauseItem() { pauseItem() {
+37 -25
View File
@@ -1,32 +1,34 @@
<template> <template>
<div @mouseover="mouseover" @mouseout="mouseout"> <nuxt-link :to="`/author/${author.id}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <div @mouseover="mouseover" @mouseleave="mouseleave">
<!-- Image or placeholder --> <div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<covers-author-image :author="author" /> <!-- Image or placeholder -->
<covers-author-image :author="author" />
<!-- Author name & num books overlay --> <!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2"> <div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p> <p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p> <p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div> </div>
<!-- Search icon btn --> <!-- Search icon btn -->
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor"> <div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span> <span class="material-icons text-lg">search</span>
</div> </div>
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)"> <div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span> <span class="material-icons text-lg">edit</span>
</div> </div>
<!-- Loading spinner --> <!-- Loading spinner -->
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center"> <div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<widgets-loading-spinner size="" /> <widgets-loading-spinner size="" />
</div>
</div>
<div v-show="nameBelow" class="w-full py-1 px-2">
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div> </div>
</div> </div>
<div v-show="nameBelow" class="w-full py-1 px-2"> </nuxt-link>
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div>
</div>
</template> </template>
<script> <script>
@@ -63,20 +65,30 @@ export default {
name() { name() {
return this._author.name || '' return this._author.name || ''
}, },
asin() {
return this._author.asin || ''
},
numBooks() { numBooks() {
return this._author.numBooks || 0 return this._author.numBooks || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
} }
}, },
methods: { methods: {
mouseover() { mouseover() {
this.isHovering = true this.isHovering = true
}, },
mouseout() { mouseleave() {
this.isHovering = false this.isHovering = false
}, },
async searchAuthor() { async searchAuthor() {
this.searching = true this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => { const payload = {}
if (this.asin) payload.asin = this.asin
else payload.q = this.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
}) })
-5
View File
@@ -109,19 +109,14 @@ export default {
hasValidCovers() { hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath) var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length return !!validCovers.length
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
} }
}, },
methods: { methods: {
mouseoverCard() { mouseoverCard() {
this.isHovering = true this.isHovering = true
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
}, },
mouseleaveCard() { mouseleaveCard() {
this.isHovering = false this.isHovering = false
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
}, },
clickCard() { clickCard() {
this.$emit('click', this.group) this.$emit('click', this.group)
+90 -19
View File
@@ -6,11 +6,11 @@
</div> </div>
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }} {{ displayTitle }}
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> </div>
@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick"> <div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div> </div>
@@ -60,7 +60,8 @@
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div> </div>
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> <!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span> <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div> </div>
</div> </div>
@@ -78,7 +79,7 @@
</ui-tooltip> </ui-tooltip>
<!-- Series sequence --> <!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div> </div>
@@ -110,7 +111,6 @@ export default {
default: 192 default: 192
}, },
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number, bookshelfView: Number,
bookMount: { bookMount: {
// Book can be passed as prop or set with setEntity() // Book can be passed as prop or set with setEntity()
@@ -147,9 +147,16 @@ export default {
showExperimentalFeatures() { showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures return this.store.state.showExperimentalFeatures
}, },
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
isFile() {
// Library item is not in a folder
return this._libraryItem.isFile
},
media() { media() {
return this._libraryItem.media || {} return this._libraryItem.media || {}
}, },
@@ -172,7 +179,7 @@ export default {
return this._libraryItem.id return this._libraryItem.id
}, },
series() { series() {
// Only included when filtering by series or collapse series // Only included when filtering by series or collapse series or Continue Series shelf on home page
return this.mediaMetadata.series return this.mediaMetadata.series
}, },
seriesSequence() { seriesSequence() {
@@ -243,8 +250,11 @@ export default {
} }
return this.title return this.title
}, },
displayAuthor() { displayLineTwo() {
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
}
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author return this.author
}, },
@@ -252,8 +262,9 @@ export default {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
return null return null
}, },
episodeProgress() { episodeProgress() {
@@ -272,19 +283,20 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
}, },
showError() { showError() {
return this.numMissingParts || this.isMissing || this.isInvalid if (this.recentEpisode) return false // Dont show podcast error on episode card
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
}, },
isStreaming() { isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode) return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -296,6 +308,10 @@ export default {
if (this.isPodcast) return 0 if (this.isPodcast) return 0
return this.media.numMissingParts return this.media.numMissingParts
}, },
numInvalidAudioFiles() {
if (this.isPodcast) return 0
return this.media.numInvalidAudioFiles
},
errorText() { errorText() {
if (this.isMissing) return 'Item directory is missing!' if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) { else if (this.isInvalid) {
@@ -304,7 +320,11 @@ export default {
} }
var txt = '' var txt = ''
if (this.numMissingParts) { if (this.numMissingParts) {
txt = `${this.numMissingParts} missing parts.` txt += `${this.numMissingParts} missing parts.`
}
if (this.numInvalidAudioFiles) {
if (txt) txt += ' '
txt += `${this.numInvalidAudioFiles} invalid audio files.`
} }
return txt || 'Unknown Error' return txt || 'Unknown Error'
}, },
@@ -329,10 +349,23 @@ export default {
userCanDownload() { userCanDownload() {
return this.store.getters['user/getUserCanDownload'] return this.store.getters['user/getUserCanDownload']
}, },
userIsRoot() { userIsAdminOrUp() {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsAdminOrUp']
}, },
moreMenuItems() { moreMenuItems() {
if (this.recentEpisode) {
return [
{
func: 'editPodcast',
text: 'Edit Podcast'
},
{
func: 'toggleFinished',
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
}
]
}
var items = [] var items = []
if (!this.isPodcast) { if (!this.isPodcast) {
items = [ items = [
@@ -356,7 +389,7 @@ export default {
text: 'Match' text: 'Match'
}) })
} }
if (this.userIsRoot) { if (this.userIsAdminOrUp && !this.isFile) {
items.push({ items.push({
func: 'rescan', func: 'rescan',
text: 'Re-Scan' text: 'Re-Scan'
@@ -397,8 +430,12 @@ export default {
var constants = this.$constants || this.$nuxt.$constants var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.TITLES return this.bookshelfView === constants.BookshelfView.TITLES
}, },
isAuthorBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.AUTHOR
},
titleDisplayBottomOffset() { titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0 if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier return 4.25 * this.sizeMultiplier
} }
@@ -408,7 +445,34 @@ export default {
this.isSelectionMode = val this.isSelectionMode = val
if (!val) this.selected = false if (!val) this.selected = false
}, },
setEntity(libraryItem) { setEntity(_libraryItem) {
var libraryItem = _libraryItem
// this code block is only necessary when showing a selected series with sequence #
// it will update the selected series so we get realtime updates for series sequence changes
if (this.series) {
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
libraryItem = {
..._libraryItem,
media: {
..._libraryItem.media,
metadata: {
..._libraryItem.media.metadata
}
}
}
var mediaMetadata = libraryItem.media.metadata
if (mediaMetadata.series) {
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
if (newSeries) {
// update selected series
libraryItem.media.metadata.series = newSeries
this.libraryItem = libraryItem
return
}
}
}
this.libraryItem = libraryItem this.libraryItem = libraryItem
}, },
clickCard(e) { clickCard(e) {
@@ -435,10 +499,14 @@ export default {
isFinished: !this.itemIsFinished isFinished: !this.itemIsFinished
} }
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
var toast = this.$toast || this.$nuxt.$toast var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios var axios = this.$axios || this.$nuxt.$axios
axios axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload) .$patch(apiEndpoint, updatePayload)
.then(() => { .then(() => {
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
@@ -449,6 +517,9 @@ export default {
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
}, },
editPodcast() {
this.$emit('editPodcast', this.libraryItem)
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
+13 -7
View File
@@ -5,20 +5,18 @@
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none"> <div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
</div> -->
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit"> <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span> <span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div> </div>
</div> </div>
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
</div> -->
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
</div> </div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div> </div>
</template> </template>
@@ -28,7 +26,11 @@ export default {
index: Number, index: Number,
width: Number, width: Number,
height: Number, height: Number,
bookCoverAspectRatio: Number bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
}
}, },
data() { data() {
return { return {
@@ -58,6 +60,10 @@ export default {
}, },
currentLibraryId() { currentLibraryId() {
return this.store.state.libraries.currentLibraryId return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.TITLES
} }
}, },
methods: { methods: {
+15 -1
View File
@@ -13,11 +13,14 @@
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p> <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
</div> </div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
</div> </div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div> </div>
</template> </template>
@@ -28,6 +31,10 @@ export default {
width: Number, width: Number,
height: Number, height: Number,
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
isCategorized: Boolean, isCategorized: Boolean,
seriesMount: { seriesMount: {
type: Object, type: Object,
@@ -61,6 +68,9 @@ export default {
books() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []
}, },
addedAt() {
return this.series ? this.series.addedAt : 0
},
seriesBookProgress() { seriesBookProgress() {
return this.books return this.books
.map((libraryItem) => { .map((libraryItem) => {
@@ -86,6 +96,10 @@ export default {
hasValidCovers() { hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath) var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length return !!validCovers.length
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.TITLES
} }
}, },
methods: { methods: {
@@ -1,73 +0,0 @@
<template>
<div>
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<!-- <div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> -->
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
authorName: String
},
data() {
return {
searchAuthor: null,
lastSearch: null,
isProcessing: false,
provider: 'audnexus',
providers: [
{
text: 'Audnexus',
value: 'audnexus'
}
]
}
},
watch: {
authorName: {
immediate: true,
handler(newVal) {
this.searchAuthor = newVal
}
}
},
computed: {},
methods: {
getSearchQuery() {
return `q=${this.searchAuthor}`
},
submitSearch() {
if (!this.searchAuthor) {
this.$toast.warning('Author name is required')
return
}
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.isProcessing = true
this.lastSearch = searchQuery
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
this.isProcessing = false
if (result) {
this.$emit('match', result)
}
}
},
mounted() {}
}
</script>
@@ -33,20 +33,20 @@ export default {
showMenu: false, showMenu: false,
items: [ items: [
{ {
text: 'Current', text: 'Pub Date',
value: 'index' value: 'publishedAt'
}, },
{ {
text: 'Title', text: 'Title',
value: 'title' value: 'title'
}, },
{ {
text: 'Episode', text: 'Season',
value: 'episode' value: 'season'
}, },
{ {
text: 'Pub Date', text: 'Episode',
value: 'publishedAt' value: 'episode'
} }
] ]
} }
+1 -1
View File
@@ -217,7 +217,7 @@ export default {
return ['Finished', 'In Progress', 'Not Started'] return ['Finished', 'In Progress', 'Not Started']
}, },
missing() { missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language'] return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
@@ -52,6 +52,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: 'Duration',
value: 'media.duration'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
@@ -78,6 +82,10 @@ export default {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
}, },
{
text: '# of Episodes',
value: 'media.numTracks'
},
{ {
text: 'File Birthtime', text: 'File Birthtime',
value: 'birthtimeMs' value: 'birthtimeMs'
@@ -131,6 +139,9 @@ export default {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
this.selected = val this.selected = val
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
this.selectedDesc = false
}
} }
this.showMenu = false this.showMenu = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
+8 -3
View File
@@ -44,6 +44,14 @@ export default {
this.$nextTick(this.init) this.$nextTick(this.init)
} }
} }
},
width: {
handler(newVal) {
if (newVal) {
this.isInit = false
this.$nextTick(this.init)
}
}
} }
}, },
computed: { computed: {
@@ -51,9 +59,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240 return this.width / 240
}, },
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() { store() {
return this.$store || this.$nuxt.$store return this.$store || this.$nuxt.$store
}, },
+35 -15
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing"> <modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -8,20 +8,20 @@
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full p-8"> <div class="w-full p-8">
<div class="flex py-2 -mx-2"> <div class="flex py-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" /> <ui-text-input-with-label v-model="newUser.username" label="Username" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" /> <ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
</div> </div>
</div> </div>
<div class="flex py-2"> <div v-show="!isEditingRoot" class="flex py-2">
<div class="px-2"> <div class="px-2 w-52">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" /> <ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2"> <div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p> <p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" /> <ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div> </div>
@@ -86,13 +86,13 @@
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" /> <ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
</div> </div>
</div> </div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4"> <div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" /> <ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
</div> </div>
</div> </div>
<div class="flex pt-4"> <div class="flex pt-4 px-2">
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn> <ui-btn color="success" type="submit">Submit</ui-btn>
</div> </div>
@@ -116,7 +116,20 @@ export default {
processing: false, processing: false,
newUser: {}, newUser: {},
isNew: true, isNew: true,
accountTypes: ['guest', 'user', 'admin'], accountTypes: [
{
text: 'Guest',
value: 'guest'
},
{
text: 'User',
value: 'user'
},
{
text: 'Admin',
value: 'admin'
}
],
tags: [], tags: [],
loadingTags: false loadingTags: false
} }
@@ -124,6 +137,7 @@ export default {
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) { if (newVal) {
this.init() this.init()
} }
@@ -140,7 +154,7 @@ export default {
} }
}, },
title() { title() {
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}` return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
}, },
isEditingRoot() { isEditingRoot() {
return this.account && this.account.type === 'root' return this.account && this.account.type === 'root'
@@ -161,10 +175,12 @@ export default {
} }
}, },
methods: { methods: {
close() {
// Force close when navigating - used in UsersTable
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) { accessAllTagsToggled(val) {
if (!val && !this.newUser.itemTagsAccessible.length) { if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = [] this.newUser.itemTagsAccessible = []
} }
}, },
@@ -197,6 +213,10 @@ export default {
this.$toast.error('Must select at least one library') this.$toast.error('Must select at least one library')
return return
} }
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
this.$toast.error('Must select at least one tag')
return
}
if (this.isNew) { if (this.isNew) {
this.submitCreateAccount() this.submitCreateAccount()
+2 -1
View File
@@ -93,12 +93,13 @@ export default {
this.show = false this.show = false
}, },
clickBg(ev) { clickBg(ev) {
if (!this.show) return
if (this.preventClickoutside) { if (this.preventClickoutside) {
this.preventClickoutside = false this.preventClickoutside = false
return return
} }
if (this.processing && this.persistent) return if (this.processing && this.persistent) return
if (ev.srcElement.classList.contains('modal-bg')) { if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }
}, },
+25 -15
View File
@@ -6,12 +6,12 @@
</div> </div>
</template> </template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> <div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form @submit.prevent="submitForm"> <form v-if="author" @submit.prevent="submitForm">
<div class="flex"> <div class="flex">
<div class="w-40 p-2"> <div class="w-40 p-2">
<div class="w-full h-45 relative"> <div class="w-full h-45 relative">
<covers-author-image :author="author" /> <covers-author-image :author="author" />
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> <div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span> <span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div> </div>
</div> </div>
@@ -43,13 +43,13 @@
<script> <script>
export default { export default {
props: { // props: {
value: Boolean, // value: Boolean,
author: { // author: {
type: Object, // type: Object,
default: () => {} // default: () => {}
} // }
}, // },
data() { data() {
return { return {
authorCopy: { authorCopy: {
@@ -73,12 +73,15 @@ export default {
computed: { computed: {
show: { show: {
get() { get() {
return this.value return this.$store.state.globals.showEditAuthorModal
}, },
set(val) { set(val) {
this.$emit('input', val) this.$store.commit('globals/setShowEditAuthorModal', val)
} }
}, },
author() {
return this.$store.state.globals.selectedAuthor
},
authorId() { authorId() {
if (!this.author) return '' if (!this.author) return ''
return this.author.id return this.author.id
@@ -112,8 +115,10 @@ export default {
return null return null
}) })
if (result) { if (result) {
if (result.updated) this.$toast.success('Author updated') if (result.updated) {
else this.$toast.info('No updates were needed') this.$toast.success('Author updated')
this.show = false
} else this.$toast.info('No updates were needed')
} }
this.processing = false this.processing = false
}, },
@@ -134,12 +139,17 @@ export default {
this.processing = false this.processing = false
}, },
async searchAuthor() { async searchAuthor() {
if (!this.authorCopy.name) { if (!this.authorCopy.name && !this.authorCopy.asin) {
this.$toast.error('Must enter an author name') this.$toast.error('Must enter an author name')
return return
} }
this.processing = true this.processing = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
const payload = {}
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
}) })
+7 -8
View File
@@ -18,7 +18,7 @@
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div> </div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative"> <div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div> </div>
</modals-modal> </modals-modal>
@@ -62,10 +62,9 @@ export default {
component: 'modals-item-tabs-match' component: 'modals-item-tabs-match'
}, },
{ {
id: 'merge', id: 'manage',
title: 'Merge', title: 'Manage',
component: 'modals-item-tabs-merge', component: 'modals-item-tabs-manage'
experimental: true
} }
] ]
} }
@@ -123,12 +122,12 @@ export default {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
if (this.mediaType == 'book' && tab.id == 'episodes') return false if (this.mediaType == 'book' && tab.id == 'episodes') return false
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate) return true if (tab.id === 'match' && this.userCanUpdate) return true
return false return false
}) })
@@ -1,32 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4"> <div class="w-full mb-4">
<div v-if="chapters.length" class="w-full p-4 bg-primary"> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
<p>Audiobook Chapters</p> <div v-if="!chapters.length" class="py-4 text-center">
<p class="mb-8 text-xl">No Chapters</p>
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
</div> </div>
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</div> </div>
</div> </div>
</template> </template>
@@ -48,6 +27,9 @@ export default {
}, },
chapters() { chapters() {
return this.media.chapters || [] return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
} }
}, },
methods: {} methods: {}
+29 -17
View File
@@ -1,23 +1,27 @@
<template> <template>
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> <div id="formWrapper" class="w-full overflow-y-auto">
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> <widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'"> <div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4"> <div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> <ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> <ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn> <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-btn @click="submitForm">Submit</ui-btn> <ui-btn @click="save" class="mx-2">Save</ui-btn>
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@@ -49,8 +53,11 @@ export default {
this.$emit('update:processing', val) this.$emit('update:processing', val)
} }
}, },
isRootUser() { isFile() {
return this.$store.getters['user/getIsRoot'] return !!this.libraryItem && this.libraryItem.isFile
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}, },
isMissing() { isMissing() {
return !!this.libraryItem && !!this.libraryItem.isMissing return !!this.libraryItem && !!this.libraryItem.isMissing
@@ -139,19 +146,23 @@ export default {
this.rescanning = false this.rescanning = false
}) })
}, },
submitForm() { async saveAndClose() {
const wasUpdated = await this.save()
if (wasUpdated !== null) this.$emit('close')
},
async save() {
if (this.isProcessing) { if (this.isProcessing) {
return return null
} }
if (!this.$refs.itemDetailsEdit) { if (!this.$refs.itemDetailsEdit) {
return return null
} }
var updatedDetails = this.$refs.itemDetailsEdit.getDetails() var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
if (!updatedDetails.hasChanges) { if (!updatedDetails.hasChanges) {
this.$toast.info('No changes were made') this.$toast.info('No changes were made')
return return false
} }
this.updateDetails(updatedDetails) return this.updateDetails(updatedDetails)
}, },
async updateDetails(updatedDetails) { async updateDetails(updatedDetails) {
this.isProcessing = true this.isProcessing = true
@@ -163,11 +174,12 @@ export default {
if (updateResult) { if (updateResult) {
if (updateResult.updated) { if (updateResult.updated) {
this.$toast.success('Item details updated') this.$toast.success('Item details updated')
// this.$emit('close') return true
} else { } else {
this.$toast.info('No updates were necessary') this.$toast.info('No updates were necessary')
} }
} }
return false
}, },
removeItem() { removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
@@ -221,8 +233,8 @@ export default {
</script> </script>
<style scoped> <style scoped>
.details-form-wrapper { #formWrapper {
height: calc(100% - 70px); height: calc(100% - 80px);
max-height: calc(100% - 70px); max-height: calc(100% - 80px);
} }
</style> </style>
@@ -1,11 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4"> <div class="w-full mb-4">
<!-- <div class="flex items-center mb-4"> <div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> <!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
<div class="flex-grow" /> <ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn> <ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
</div> --> </div>
<div v-if="episodes.length" class="w-full p-4 bg-primary"> <div v-if="episodes.length" class="w-full p-4 bg-primary">
<p>Podcast Episodes</p> <p>Podcast Episodes</p>
@@ -51,10 +51,23 @@ export default {
}, },
data() { data() {
return { return {
checkingNewEpisodes: false checkingNewEpisodes: false,
lastEpisodeCheckInput: null
}
},
watch: {
lastEpisodeCheck: {
handler(newVal) {
if (newVal) {
this.setLastEpisodeCheckInput()
}
}
} }
}, },
computed: { computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
autoDownloadEpisodes() { autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes return !!this.media.autoDownloadEpisodes
}, },
@@ -72,8 +85,22 @@ export default {
} }
}, },
methods: { methods: {
checkForNewEpisodes() { async checkForNewEpisodes() {
if (this.$refs.lastCheckInput) {
this.$refs.lastCheckInput.blur()
}
this.checkingNewEpisodes = true this.checkingNewEpisodes = true
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
// If last episode check changed then update it first
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
console.error('Failed to update', error)
return false
})
console.log('updateResult', updateResult)
}
this.$axios this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`) .$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.then((response) => { .then((response) => {
@@ -91,7 +118,13 @@ export default {
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
this.checkingNewEpisodes = false this.checkingNewEpisodes = false
}) })
},
setLastEpisodeCheckInput() {
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
} }
},
mounted() {
this.setLastEpisodeCheckInput()
} }
} }
</script> </script>
@@ -1,9 +1,5 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-for="audiobook in audiobooks">
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
</template>
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" /> <tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
</div> </div>
</template> </template>
@@ -51,12 +47,6 @@ export default {
}, },
showDownload() { showDownload() {
return this.userCanDownload && !this.isMissing return this.userCanDownload && !this.isMissing
},
audiobooks() {
return this.media.audiobooks || []
},
ebooks() {
return this.media.ebooks || []
} }
}, },
methods: { methods: {
@@ -1,10 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8"> <div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p> <p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p> <p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div>
@@ -24,13 +25,55 @@
</div> </div>
</div> </div>
<p class="text-left text-base mb-4 py-4"> <!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
<div v-else>
<div class="flex">
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
</div>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
</div>
</div>
</div>
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span <span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes. >&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
</p> </p>
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> <!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p> <p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20"> <div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
@@ -70,6 +113,9 @@ export default {
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem ? this.libraryItem.id : null
}, },
@@ -97,9 +143,16 @@ export default {
isSingleM4b() { isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b' return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
}, },
chapters() {
return this.media.chapters || []
},
showM4bDownload() { showM4bDownload() {
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false if (!this.mediaTracks.length) return false
return !this.isSingleM4b && this.mediaTracks.length > 0 return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
} }
}, },
methods: { methods: {
@@ -28,10 +28,9 @@
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" /> <ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div> </div>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">Browse for Folder</ui-btn>
</div> </div>
</div> </div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" /> <modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div> </div>
</template> </template>
@@ -77,6 +76,9 @@ export default {
} }
}, },
methods: { methods: {
browseForFolder() {
this.showDirectoryPicker = true
},
getLibraryData() { getLibraryData() {
return { return {
name: this.name, name: this.name,
@@ -93,7 +93,9 @@ export default {
icon: 'database', icon: 'database',
mediaType: 'book', mediaType: 'book',
settings: { settings: {
disableWatcher: false disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
} }
} }
}, },
@@ -191,6 +193,11 @@ export default {
this.processing = false this.processing = false
this.show = false this.show = false
this.$toast.success(`Library "${res.name}" created successfully`) this.$toast.success(`Library "${res.name}" created successfully`)
if (!this.$store.state.libraries.currentLibraryId) {
console.log('Setting initially library id', res.id)
// First library added
this.$store.dispatch('libraries/fetch', res.id)
}
}) })
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
@@ -8,6 +8,18 @@
</div> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div> </div>
<div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
</div>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
</div>
</div>
</div> </div>
</template> </template>
@@ -23,7 +35,9 @@ export default {
data() { data() {
return { return {
provider: null, provider: null,
disableWatcher: false disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
} }
}, },
computed: { computed: {
@@ -45,7 +59,9 @@ export default {
getLibraryData() { getLibraryData() {
return { return {
settings: { settings: {
disableWatcher: !!this.disableWatcher disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
} }
} }
}, },
@@ -54,6 +70,8 @@ export default {
}, },
init() { init() {
this.disableWatcher = !!this.librarySettings.disableWatcher this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
} }
}, },
mounted() { mounted() {
@@ -7,13 +7,16 @@
</template> </template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden"> <div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="w-1/3 p-1"> <div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" /> <ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div> </div>
<div class="w-1/3 p-1"> <div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" /> <ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div> </div>
<div class="w-1/3 p-1"> <div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" /> <ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
</div> </div>
<div class="w-full p-1"> <div class="w-full p-1">
@@ -39,6 +42,7 @@ export default {
return { return {
processing: false, processing: false,
newEpisode: { newEpisode: {
season: null,
episode: null, episode: null,
episodeType: null, episodeType: null,
title: null, title: null,
@@ -92,6 +96,7 @@ export default {
} }
}, },
init() { init() {
this.newEpisode.season = this.episode.season || ''
this.newEpisode.episode = this.episode.episode || '' this.newEpisode.episode = this.episode.episode || ''
this.newEpisode.episodeType = this.episode.episodeType || '' this.newEpisode.episodeType = this.episode.episodeType || ''
this.newEpisode.title = this.episode.title || '' this.newEpisode.title = this.episode.title || ''
@@ -148,6 +148,7 @@ export default {
}) })
}, },
init() { init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
for (let i = 0; i < this.episodes.length; i++) { for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i] var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
@@ -151,7 +151,7 @@ export default {
this.fullPath = '' this.fullPath = ''
return return
} }
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title) this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
}, },
submit() { submit() {
const podcastPayload = { const podcastPayload = {
@@ -0,0 +1,90 @@
<template>
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="mb-4">
<p class="text-lg text-gray-200 mb-4">
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
>?
</p>
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
</div>
<div class="flex justify-between items-center pt-4">
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
hardDeleteFile: false,
processing: false
}
},
watch: {
value(newVal) {
if (newVal) this.hardDeleteFile = false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return 'Remove Episode'
},
episodeId() {
return this.episode ? this.episode.id : null
},
episodeTitle() {
return this.episode ? this.episode.title : null
}
},
methods: {
submit() {
this.processing = true
var queryString = this.hardDeleteFile ? '?hard=1' : ''
this.$axios
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
.then(() => {
this.processing = false
this.$toast.success('Podcast episode removed')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>
@@ -0,0 +1,166 @@
<template>
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="currentFeedUrl" class="w-full">
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
<div class="w-full relative">
<ui-text-input v-model="currentFeedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
</div>
</div>
<div v-else class="w-full">
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
<div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
</div>
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
feedUrl: String
},
data() {
return {
processing: false,
newFeedSlug: null,
currentFeedUrl: null
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}`
},
isHttp() {
return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
}
},
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
return
}
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
return
}
const payload = {
serverAddress: window.origin,
slug: this.newFeedSlug
}
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
console.log('Payload', payload)
this.$axios
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
.then((data) => {
if (data.success) {
console.log('Opened RSS Feed', data)
this.currentFeedUrl = data.feedUrl
} else {
const errorMsg = data.error || 'Unknown error'
this.$toast.error(errorMsg)
}
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
this.$toast.error()
})
},
copyToClipboard(str) {
this.$copyToClipboard(str, this)
},
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
.then(() => {
this.$toast.success('RSS Feed Closed')
this.show = false
this.processing = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.processing = false
this.$toast.error()
})
},
init() {
if (!this.libraryItem) return
this.newFeedSlug = this.libraryItem.id
this.currentFeedUrl = this.feedUrl
}
},
mounted() {}
}
</script>
+273
View File
@@ -0,0 +1,273 @@
<template>
<div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
<div class="flex-grow" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
daysListening: {
type: Object,
default: () => {}
}
},
data() {
return {
contentWidth: 0,
maxInnerWidth: 0,
innerHeight: 13 * 7,
blockWidth: 13,
data: [],
monthLabels: [],
tooltipEl: null,
tooltipTextEl: null,
tooltipArrowEl: null,
showingTooltipIndex: -1,
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
// GH Colors
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
}
},
computed: {
weeksToShow() {
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
},
innerWidth() {
return (this.weeksToShow + 1) * 13
},
daysToShow() {
return this.weeksToShow * 7 + this.dayOfWeekToday
},
dayOfWeekToday() {
return new Date().getDay()
},
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() {
return [
{
label: 'Mon',
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Wed',
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Fri',
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
fontSize: '10px'
}
}
]
},
legendBlocks() {
return [
{
id: 'legend-0',
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
},
{
id: 'legend-1',
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-2',
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-3',
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-4',
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
}
]
}
},
methods: {
destroyTooltip() {
if (this.tooltipEl) this.tooltipEl.remove()
this.tooltipEl = null
this.showingTooltipIndex = -1
},
createTooltip() {
const tooltip = document.createElement('div')
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
tooltip.style.display = 'none'
tooltip.id = 'heatmap-tooltip'
const tooltipText = document.createElement('p')
tooltipText.innerText = 'Tooltip'
tooltipText.style.fontSize = '10px'
tooltipText.style.lineHeight = '10px'
tooltip.appendChild(tooltipText)
const tooltipArrow = document.createElement('div')
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
tooltip.appendChild(tooltipArrow)
this.tooltipEl = tooltip
this.tooltipTextEl = tooltipText
this.tooltipArrowEl = tooltipArrow
document.body.appendChild(this.tooltipEl)
},
showTooltip(index, block, rect) {
if (this.tooltipEl && this.showingTooltipIndex === index) return
if (!this.tooltipEl) {
this.createTooltip()
}
this.showingTooltipIndex = index
this.tooltipEl.style.display = 'block'
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
const calculateRect = this.tooltipEl.getBoundingClientRect()
const w = calculateRect.width / 2
var left = rect.x - w
var offsetX = 0
if (left < 0) {
offsetX = Math.abs(left)
left = 0
} else if (rect.x + w > window.innerWidth - 10) {
offsetX = window.innerWidth - 10 - (rect.x + w)
left += offsetX
}
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
},
hideTooltip() {
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
this.tooltipEl.style.display = 'none'
this.showingTooltipIndex = -1
}
},
mouseover(e) {
if (isNaN(e.target.dataset.index)) {
this.hideTooltip()
return
}
var block = this.data[e.target.dataset.index]
var rect = e.target.getBoundingClientRect()
this.showTooltip(e.target.dataset.index, block, rect)
},
mouseout(e) {
this.hideTooltip()
},
buildData() {
this.data = []
var maxValue = 0
var minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
for (let i = 0; i < this.daysToShow + 1; i++) {
const col = Math.floor(i / 7)
const row = i % 7
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red'
}
this.data.push({
date,
dateString,
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
for (let i = 0; i < this.data.length; i++) {
if (this.data[i].monthString !== lastMonth) {
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
if (weekOfMonth <= 2) {
this.monthLabels.push({
id: this.data[i].dateString + '-ml',
label: this.data[i].monthString,
style: {
transform: `translate(${this.data[i].col * 13}px, -15px)`,
lineHeight: '10px',
fontSize: '10px'
}
})
lastMonth = this.data[i].monthString
}
}
}
},
init() {
const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52
this.buildData()
}
},
updated() {},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,74 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Chapters</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
keepOpen: Boolean
},
data() {
return {
expanded: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.expanded = !this.expanded
}
},
mounted() {}
}
</script>
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> --> <!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent> <nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -59,7 +59,8 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
libraryItemId: String libraryItemId: String,
isFile: Boolean
}, },
data() { data() {
return { return {
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent> <div class="w-full my-4">
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">{{ title }}</p> <p class="pr-4">{{ title }}</p>
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span> <span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
+10 -3
View File
@@ -45,8 +45,8 @@
</td> </td>
<td class="py-0"> <td class="py-0">
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> --> <!-- Dont show edit for non-root users -->
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)"> <div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span> <span class="material-icons text-base">edit</span>
</div> </div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)"> <div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
@@ -58,7 +58,7 @@
</table> </table>
</div> </div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" /> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
</template> </template>
@@ -76,6 +76,9 @@ export default {
currentUserId() { currentUserId() {
return this.$store.state.user.user.id return this.$store.state.user.user.id
}, },
userIsRoot() {
return this.$store.getters['user/getIsRoot']
},
usersOnline() { usersOnline() {
var usermap = {} var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session })) this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
@@ -156,6 +159,10 @@ export default {
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {
if (this.$refs.accountModal) {
this.$refs.accountModal.close()
}
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded) this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated) this.$root.socket.off('user_updated', this.userUpdated)
@@ -14,12 +14,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="w-80 h-full px-2 flex items-center"> <div class="flex-grow max-w-md h-full px-2 flex items-center">
<div> <div class="truncate px-1">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link> <nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
</div> </div>
</div> </div>
<div class="flex-grow flex items-center"> <div class="w-20 flex items-center">
<p class="font-mono text-sm">{{ bookDuration }}</p> <p class="font-mono text-sm">{{ bookDuration }}</p>
</div> </div>
@@ -6,18 +6,20 @@
<span class="material-icons" style="font-size: 1.4rem">add</span> <span class="material-icons" style="font-size: 1.4rem">add</span>
</div> </div>
</div> </div>
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div> </div>
</template> </template>
</draggable> </draggable>
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" /> <div v-if="!libraries.length" class="pb-4">
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
</div>
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p> <p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p> <p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
</div> </div>
</template> </template>
@@ -32,8 +34,6 @@ export default {
return { return {
libraryCopies: [], libraryCopies: [],
currentOrder: [], currentOrder: [],
showLibraryModal: false,
selectedLibrary: null,
drag: false, drag: false,
dragOptions: { dragOptions: {
animation: 200, animation: 200,
@@ -97,12 +97,10 @@ export default {
this.$router.push(`/library/${library.id}`) this.$router.push(`/library/${library.id}`)
}, },
clickAddLibrary() { clickAddLibrary() {
this.selectedLibrary = null this.$emit('showLibraryModal', null)
this.showLibraryModal = true
}, },
editLibrary(library) { editLibrary(library) {
this.selectedLibrary = library this.$emit('showLibraryModal', library)
this.showLibraryModal = true
}, },
init() { init() {
this.libraryCopies = this.libraries.map((lib) => { this.libraryCopies = this.libraries.map((lib) => {
@@ -73,10 +73,28 @@ export default {
this.$emit('edit', this.library) this.$emit('edit', this.library)
}, },
scan() { scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id }) this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}, },
forceScan() { forceScan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 }) if (confirm(`Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed to be used for the library item.\n\nAre you sure you want to force re-scan?`)) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}
}, },
deleteClick() { deleteClick() {
if (this.isMain) return if (this.isMain) return
@@ -1,11 +1,6 @@
<template> <template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center h-24"> <div v-if="episode" class="flex items-center h-24">
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div>
</div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
{{ title }} {{ title }}
@@ -20,6 +15,7 @@
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top"> <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip> </ui-tooltip>
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p> <p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p> <p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
</div> </div>
@@ -48,8 +44,7 @@ export default {
episode: { episode: {
type: Object, type: Object,
default: () => {} default: () => {}
}, }
isDragging: Boolean
}, },
data() { data() {
return { return {
@@ -58,15 +53,6 @@ export default {
isHovering: false isHovering: false
} }
}, },
watch: {
isDragging: {
handler(newVal) {
if (newVal) {
this.isHovering = false
}
}
}
},
computed: { computed: {
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
@@ -116,7 +102,7 @@ export default {
}, },
methods: { methods: {
mouseover() { mouseover() {
if (this.isDragging) return // if (this.isDragging) return
this.isHovering = true this.isHovering = true
}, },
mouseleave() { mouseleave() {
@@ -153,22 +139,7 @@ export default {
}) })
}, },
removeClick() { removeClick() {
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) { this.$emit('remove', this.episode)
this.processingRemove = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
.then((updatedPodcast) => {
console.log(`Episode removed from podcast`, updatedPodcast)
this.$toast.success('Episode removed from podcast')
this.processingRemove = false
})
.catch((error) => {
console.error('Failed to remove episode from podcast', error)
this.$toast.error('Failed to remove episode from podcast')
this.processingRemove = false
})
}
} }
} }
} }
@@ -3,29 +3,19 @@
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">Episodes</p> <p class="text-lg mb-0 font-semibold">Episodes</p>
<div class="flex-grow" /> <div class="flex-grow" />
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" /> <controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div v-if="userCanUpdate" class="w-12">
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
</div>
</div> </div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p> <p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <template v-for="episode in episodesSorted">
<transition-group type="transition" :name="!drag ? 'episode' : null"> <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
<template v-for="episode in episodesCopy"> </template>
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
</template> <modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
</transition-group>
</draggable>
</div> </div>
</template> </template>
<script> <script>
import draggable from 'vuedraggable'
export default { export default {
components: {
draggable
},
props: { props: {
libraryItem: { libraryItem: {
type: Object, type: Object,
@@ -34,30 +24,19 @@ export default {
}, },
data() { data() {
return { return {
sortKey: 'index',
sortDesc: true,
drag: false,
episodesCopy: [], episodesCopy: [],
orderChanged: false, sortKey: 'publishedAt',
savingOrder: false sortDesc: true,
selectedEpisode: null,
showPodcastRemoveModal: false
} }
}, },
watch: { watch: {
libraryItem: { libraryItem() {
handler(newVal) { this.init()
this.init()
}
} }
}, },
computed: { computed: {
dragOptions() {
return {
animation: 200,
group: 'description',
ghostClass: 'ghost',
disabled: !this.userCanUpdate
}
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
@@ -69,64 +48,28 @@ export default {
}, },
episodes() { episodes() {
return this.media.episodes || [] return this.media.episodes || []
} },
}, episodesSorted() {
methods: { return this.episodesCopy.sort((a, b) => {
changeSort() {
this.episodesCopy.sort((a, b) => {
if (this.sortDesc) { if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
} }
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}) })
}
this.orderChanged = this.checkHasOrderChanged() },
}, methods: {
checkHasOrderChanged() { removeEpisode(episode) {
for (let i = 0; i < this.episodesCopy.length; i++) { this.selectedEpisode = episode
var epc = this.episodesCopy[i] this.showPodcastRemoveModal = true
var ep = this.episodes[i]
if (epc.index != ep.index) {
return true
}
}
return false
}, },
editEpisode(episode) { editEpisode(episode) {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
}, },
draggableUpdate() {
this.orderChanged = this.checkHasOrderChanged()
},
async saveOrder() {
if (!this.userCanUpdate) return
this.savingOrder = true
var episodesUpdate = {
episodes: this.episodesCopy.map((b) => b.id)
}
await this.$axios
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
.then((podcast) => {
console.log('Podcast updated', podcast)
this.$toast.success('Saved episode order')
this.orderChanged = false
})
.catch((error) => {
console.error('Failed to update podcast', error)
this.$toast.error('Failed to save podcast episode order')
})
this.savingOrder = false
},
init() { init() {
this.episodesCopy = this.episodes.map((ep) => { this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
return {
...ep
}
})
} }
}, },
mounted() { mounted() {
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
+25 -4
View File
@@ -1,9 +1,12 @@
<template> <template>
<div class="relative"> <div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
</div>
</div> </div>
</template> </template>
@@ -31,7 +34,10 @@ export default {
clearable: Boolean clearable: Boolean
}, },
data() { data() {
return {} return {
showPassword: false,
isHovering: false
}
}, },
computed: { computed: {
inputValue: { inputValue: {
@@ -49,6 +55,10 @@ export default {
if (this.noSpinner) _list.push('no-spinner') if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center') if (this.textCenter) _list.push('text-center')
return _list.join(' ') return _list.join(' ')
},
actualType() {
if (this.type === 'password' && this.showPassword) return 'text'
return this.type
} }
}, },
methods: { methods: {
@@ -69,9 +79,20 @@ export default {
}, },
blur() { blur() {
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
} }
}, },
mounted() {} mounted() {
if (this.type === 'password' && this.$refs.wrapper) {
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
}
}
} }
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''"> <p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> {{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p> </p>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" /> <ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
+3 -2
View File
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :library-item-id="libraryItemId" class="mt-6" /> <tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div> </div>
</template> </template>
@@ -27,7 +27,8 @@ export default {
media: { media: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
isFile: Boolean
}, },
data() { data() {
return {} return {}
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio / 1.25
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author)
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>
+51 -53
View File
@@ -1,66 +1,64 @@
<template> <template>
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm"> <form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto"> <div class="flex -mx-1">
<div class="flex -mx-1"> <div class="w-1/2 px-1">
<div class="w-1/2 px-1"> <ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
</div>
</div> </div>
<div class="flex-grow px-1">
<div class="flex mt-2 -mx-1"> <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
<div class="w-3/4 px-1">
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
</div>
</div> </div>
</div>
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="flex-grow px-1"> <div class="w-3/4 px-1">
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" /> <!-- Authors filter only contains authors in this library, use query input to query all authors -->
</div> <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div> </div>
<div class="flex-grow px-1">
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" /> <ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div> </div>
</div>
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="flex-grow px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" /> <ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
</div>
</div> </div>
</div>
<div class="flex mt-2 -mx-1"> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" /> <div class="flex mt-2 -mx-1">
</div> <div class="w-1/2 px-1">
<div class="w-1/4 px-1"> <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" /> </div>
</div> <div class="flex-grow px-1">
<div class="flex-grow px-1 pt-6"> <ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
<div class="flex justify-center"> </div>
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> </div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div>
<div class="flex-grow px-1 pt-6">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
</div> </div>
</div> </div>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
editPodcast(libraryItem) {
var itemIds = this.items.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', libraryItem)
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
this.items.forEach((ent) => {
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
})
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>
+143
View File
@@ -0,0 +1,143 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editItem(libraryItem) {
var itemIds = this.items.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', libraryItem)
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
this.items.forEach((item) => {
var component = this.$refs[`slider-item-${item.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(item.id)
})
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="absolute w-32 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0"> <div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)"> <div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
@@ -1,50 +1,48 @@
<template> <template>
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm"> <form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto"> <div class="flex -mx-1">
<div class="flex -mx-1"> <div class="w-1/2 px-1">
<div class="w-1/2 px-1"> <ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
</div>
</div> </div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" /> <ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div> </div>
</div>
<div class="flex mt-2 -mx-1"> <ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" /> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
</div>
<div class="w-1/4 px-1"> <div class="flex mt-2 -mx-1">
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" /> <div class="w-1/2 px-1">
</div> <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div>
<div class="flex-grow px-1 pt-6">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div> </div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div>
<div class="flex-grow px-1 pt-6"> <div class="flex-grow px-1 pt-6">
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div> </div>
</div> </div>
<div class="flex-grow px-1 pt-6">
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</form> </form>
</div> </div>
</template> </template>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.TITLES" class="relative mx-2" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return 2 * (this.cardHeight / this.bookCoverAspectRatio)
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>
+73 -20
View File
@@ -2,7 +2,10 @@
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg"> <div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar /> <app-appbar />
<Nuxt /> <app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
<Nuxt />
</div>
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
@@ -11,6 +14,7 @@
<modals-edit-collection-modal /> <modals-edit-collection-modal />
<modals-bookshelf-texture-modal /> <modals-bookshelf-texture-modal />
<modals-podcast-edit-episode /> <modals-podcast-edit-episode />
<modals-authors-edit-modal />
<readers-reader /> <readers-reader />
</div> </div>
</template> </template>
@@ -44,6 +48,13 @@ export default {
}, },
isCasting() { isCasting() {
return this.$store.state.globals.isCasting return this.$store.state.globals.isCasting
},
isShowingSideRail() {
if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
},
appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0
} }
}, },
methods: { methods: {
@@ -106,12 +117,6 @@ export default {
} }
} }
if (payload.serverSettings) { if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
if (payload.serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
require('@/plugins/chromecast.js').default(this)
}
} }
// Start scans currently running // Start scans currently running
@@ -167,17 +172,40 @@ export default {
libraryUpdated(library) { libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library) this.$store.commit('libraries/addUpdate', library)
}, },
libraryRemoved(library) { async libraryRemoved(library) {
console.log('Library removed', library)
this.$store.commit('libraries/remove', library) this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library
const currLibraryId = this.$store.state.libraries.currentLibraryId
if (currLibraryId === library.id) {
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
if (nextLibrary) {
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
if (this.$route.name.startsWith('config')) {
// No need to refresh
} else if (this.$route.name.startsWith('library')) {
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
this.$router.push(newRoute)
} else {
this.$router.push(`/library/${nextLibrary.id}`)
}
} else {
console.error('User has no more accessible libraries')
this.$store.commit('libraries/setCurrentLibrary', null)
}
}
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem) this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
}, },
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
} }
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
}, },
libraryItemRemoved(item) { libraryItemRemoved(item) {
if (this.$route.name.startsWith('item')) { if (this.$route.name.startsWith('item')) {
@@ -485,6 +513,25 @@ export default {
}, },
resize() { resize() {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight }) this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
},
checkVersionUpdate() {
// Version check is only run if time since last check was 5 minutes
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
this.$store
.dispatch('checkForUpdate')
.then((res) => {
localStorage.setItem('lastVerCheck', Date.now())
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
} }
}, },
beforeMount() { beforeMount() {
@@ -503,17 +550,7 @@ export default {
this.$store.commit('setExperimentalFeatures', true) this.$store.commit('setExperimentalFeatures', true)
} }
this.$store this.checkVersionUpdate()
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
@@ -526,4 +563,20 @@ export default {
.Vue-Toastification__toast-body.custom-class-1 { .Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px; font-size: 14px;
} }
#app-content {
width: 100%;
}
#app-content.has-siderail {
width: calc(100% - 80px);
max-width: calc(100% - 80px);
margin-left: 80px;
}
@media (max-width: 768px) {
#app-content.has-siderail {
width: 100%;
max-width: 100%;
margin-left: 0px;
}
}
</style> </style>
-30
View File
@@ -1,30 +0,0 @@
export default function (context) {
if (process.client) {
var route = context.route
var from = context.from
var store = context.store
if (route.name === 'login' || from.name === 'login') return
if (!route.name) {
console.warn('No Route name', route)
return
}
if (store.state.isRoutingBack) {
// pressing back button in appbar do not add to route history
store.commit('setIsRoutingBack', false)
return
}
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)
store.commit('setRouteHistory', _history)
}
}
}
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ export default {
bookCoverAspectRatio: this.bookCoverAspectRatio, bookCoverAspectRatio: this.bookCoverAspectRatio,
bookshelfView: this.bookshelfView bookshelfView: this.bookshelfView
} }
if (this.entityName === 'series-books') props.showSequence = true
if (this.entityName === 'books') { if (this.entityName === 'books') {
props.filterBy = this.filterBy props.filterBy = this.filterBy
props.orderBy = this.orderBy props.orderBy = this.orderBy
+19 -8
View File
@@ -112,11 +112,22 @@ export default {
items: [] items: []
}) })
var newtreemap = currtreemap.items[currtreemap.items.length - 1] var newtreemap = currtreemap.items[currtreemap.items.length - 1]
dirReader.readEntries((entries) => {
let entriesPromises = [] let entriesPromises = []
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap)) // readEntries returns 100 items max, continue calling readEntries until empty
resolve(Promise.all(entriesPromises)) function readEntries() {
}) dirReader.readEntries((entries) => {
if (entries.length > 0) {
for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
}
readEntries()
} else {
resolve(Promise.all(entriesPromises))
}
})
}
readEntries()
} }
}) })
} }
@@ -174,8 +185,8 @@ export default {
if (mediaType === 'podcast') return this.cleanPodcast(item, index) if (mediaType === 'podcast') return this.cleanPodcast(item, index)
return this.cleanBook(item, index) return this.cleanBook(item, index)
}, },
async getItemsFromDataTransferItems(items, mediaType) { async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(items) var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' } if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType) var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) { if (!itemData.items.length && !itemData.ignoredFiles.length) {
@@ -189,7 +200,7 @@ export default {
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
} }
return ab.itemFiles.length return ab.itemFiles.length
}).map(ab => this.cleanItem(ab, index++)) }).map(ab => this.cleanItem(ab, mediaType, index++))
return { return {
items, items,
ignoredFiles ignoredFiles
+2 -6
View File
@@ -9,7 +9,6 @@ module.exports = {
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333', serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
chromecastReceiver: 'FD1F76C5' chromecastReceiver: 'FD1F76C5'
}, },
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
telemetry: false, telemetry: false,
publicRuntimeConfig: { publicRuntimeConfig: {
@@ -33,14 +32,11 @@ module.exports = {
} }
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
] ]
}, },
router: { router: {},
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css // Global CSS: https://go.nuxtjs.dev/config-css
css: [ css: [
+701 -1604
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.2", "version": "2.0.16",
"description": "Audiobook manager and player", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nuxt", "dev": "nuxt",
+5 -2
View File
@@ -15,8 +15,8 @@
<div class="w-full h-px bg-primary my-4" /> <div class="w-full h-px bg-primary my-4" />
<p class="mb-4 text-lg">Change Password</p> <p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
<form @submit.prevent="submitChangePassword"> <form v-if="!isGuest" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" /> <ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" /> <ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" /> <ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
@@ -60,6 +60,9 @@ export default {
}, },
isRoot() { isRoot() {
return this.usertype === 'root' return this.usertype === 'root'
},
isGuest() {
return this.usertype === 'guest'
} }
}, },
methods: { methods: {
+422
View File
@@ -0,0 +1,422 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span>
</button>
<div class="flex-grow" />
<p class="text-base">Duration:</p>
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="flex-grow" />
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" />
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div>
<div class="w-32 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
<div class="w-40"></div>
</div>
<template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
</div>
<div class="w-40 px-2 py-1">
<div class="flex items-center">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-icons-outlined text-base">remove</span>
</button>
<ui-tooltip text="Insert chapter below" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<span class="material-icons text-lg">add</span>
</button>
</ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
<span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-icons-outlined text-lg">error_outline</span>
</button>
</ui-tooltip>
</div>
</div>
</div>
</template>
</div>
<div class="w-full max-w-xl py-4">
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">Filename</div>
<div class="w-20">Duration</div>
<div class="w-20 text-center">Chapters</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
</div>
<div class="w-20 flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
</div>
</div>
</template>
</div>
</div>
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
</div>
<div v-else class="w-full p-4">
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
</div>
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
<div class="w-24 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
</div>
<div class="w-full max-h-80 overflow-y-auto my-2">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
<div class="w-24 min-w-24 px-2">
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
</div>
<div class="flex-grow px-2">
<p class="truncate max-w-sm">{{ chapter.title }}</p>
</div>
</div>
</div>
<div class="flex pt-2">
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
</div>
</div>
</div>
</modals-modal>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!libraryItem) {
console.error('Not found...', params.id)
return redirect('/')
}
if (libraryItem.mediaType != 'book') {
console.error('Invalid media type')
return redirect('/')
}
return {
libraryItem
}
},
data() {
return {
newChapters: [],
selectedChapter: null,
audioEl: null,
isPlayingChapter: false,
isLoadingChapter: false,
currentTrackIndex: 0,
saving: false,
asinInput: null,
findingChapters: false,
showFindChaptersModal: false,
chapterData: null
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
userToken() {
return this.$store.getters['user/getToken']
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
mediaDuration() {
return this.media.duration
},
chapters() {
return this.media.chapters || []
},
tracks() {
return this.media.tracks || []
},
audioFiles() {
return this.media.audioFiles || []
},
audioTracks() {
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
},
selectedChapterId() {
return this.selectedChapter ? this.selectedChapter.id : null
}
},
methods: {
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
},
addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = {
id: chapter.id + 1,
start: chapter.start,
end: chapter.end,
title: ''
}
this.newChapters.splice(chapter.id + 1, 0, newChapter)
this.checkChapters()
},
removeChapter(chapter) {
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
this.checkChapters()
},
checkChapters() {
var previousStart = 0
for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start)
if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = 'First chapter must start at 0'
} else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
} else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration'
} else {
this.newChapters[i].error = null
}
previousStart = this.newChapters[i].start
}
},
playChapter(chapter) {
console.log('Play Chapter', chapter.id)
if (this.selectedChapterId === chapter.id) {
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
if (this.isLoadingChapter) return
if (this.isPlayingChapter) {
console.log('Destroying chapter')
this.destroyAudioEl()
return
}
}
if (this.selectedChapterId) {
this.destroyAudioEl()
}
const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
})
console.log('audio track', audioTrack)
this.selectedChapter = chapter
this.isLoadingChapter = true
const trackOffset = chapter.start - audioTrack.startOffset
this.playTrackAtTime(audioTrack, trackOffset)
},
playTrackAtTime(audioTrack, trackOffset) {
this.currentTrackIndex = audioTrack.index
const audioEl = this.audioEl || document.createElement('audio')
var src = audioTrack.contentUrl + `?token=${this.userToken}`
if (this.$isDev) {
src = `http://localhost:3333${src}`
}
console.log('src', src)
audioEl.src = src
audioEl.id = 'chapter-audio'
document.body.appendChild(audioEl)
audioEl.addEventListener('loadeddata', () => {
console.log('Audio loaded data', audioEl.duration)
audioEl.currentTime = trackOffset
audioEl.play()
console.log('Playing audio at current time', trackOffset)
})
audioEl.addEventListener('play', () => {
console.log('Audio playing')
this.isLoadingChapter = false
this.isPlayingChapter = true
})
audioEl.addEventListener('ended', () => {
console.log('Audio ended')
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
if (nextTrack) {
console.log('Playing next track', nextTrack.index)
this.currentTrackIndex = nextTrack.index
this.playTrackAtTime(nextTrack, 0)
} else {
console.log('No next track')
this.destroyAudioEl()
}
})
this.audioEl = audioEl
},
destroyAudioEl() {
if (!this.audioEl) return
this.audioEl.remove()
this.audioEl = null
this.selectedChapter = null
this.isPlayingChapter = false
this.isLoadingChapter = false
},
saveChapters() {
this.checkChapters()
for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors')
return
}
if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles')
return
}
const nextChapter = this.newChapters[i + 1]
if (nextChapter) {
this.newChapters[i].end = nextChapter.start
} else {
this.newChapters[i].end = this.mediaDuration
}
}
this.saving = true
console.log('udpated chapters', this.newChapters)
const payload = {
chapters: this.newChapters
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
this.saving = false
if (data.updated) {
this.$toast.success('Chapters updated')
this.$router.push(`/item/${this.libraryItem.id}`)
} else {
this.$toast.info('No changes needed updating')
}
})
.catch((error) => {
this.saving = false
console.error('Failed to update chapters', error)
this.$toast.error('Failed to update chapters')
})
},
applyChapterData() {
var index = 0
this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return {
id: index++,
start: chap.startOffsetMs / 1000,
end: chapEnd,
title: chap.title
}
})
this.showFindChaptersModal = false
this.chapterData = null
},
findChapters() {
if (!this.asinInput) {
this.$toast.error('Must input an ASIN')
return
}
this.findingChapters = true
this.chapterData = null
this.$axios
.$get(`/api/search/chapters?asin=${this.asinInput}`)
.then((data) => {
this.findingChapters = false
if (data.error) {
this.$toast.error(data.error)
this.showFindChaptersModal = false
} else {
console.log('Chapter data', data)
this.chapterData = data
}
})
.catch((error) => {
this.findingChapters = false
console.error('Failed to get chapter data', error)
this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false
})
}
},
mounted() {
this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
}
}
</script>
+6 -8
View File
@@ -38,7 +38,7 @@
</div> </div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null"> <transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center"> <li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
<div class="font-book text-center px-4 py-1 w-12"> <div class="font-book text-center px-4 py-1 w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }} {{ audio.include ? index - numExcluded + 1 : -1 }}
</div> </div>
@@ -71,7 +71,7 @@
<div class="font-sans text-xs font-normal w-56"> <div class="font-sans text-xs font-normal w-56">
{{ audio.error }} {{ audio.error }}
</div> </div>
<div class="font-sans text-xs font-normal w-40 flex justify-center"> <div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" /> <ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
</div> </div>
</li> </li>
@@ -89,9 +89,6 @@ export default {
draggable draggable
}, },
async asyncData({ store, params, app, redirect, route }) { async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getUserCanUpdate']) { if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized') return redirect('/?error=unauthorized')
} }
@@ -107,6 +104,10 @@ export default {
console.error('Invalid media type') console.error('Invalid media type')
return redirect('/') return redirect('/')
} }
if (libraryItem.isFile) {
console.error('No need to edit library item that is 1 file...')
return redirect('/')
}
return { return {
libraryItem, libraryItem,
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : [] files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
@@ -158,9 +159,6 @@ export default {
}, },
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
} }
}, },
methods: { methods: {
+112
View File
@@ -0,0 +1,112 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div class="max-w-6xl mx-auto">
<div class="flex mb-6">
<div class="w-48 min-w-48">
<div class="w-full h-52">
<covers-author-image :author="author" rounded="0" />
</div>
</div>
<div class="flex-grow px-8">
<div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1>
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
<span class="material-icons text-base">edit</span>
</button>
</div>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
</div>
</div>
<div class="py-4">
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
</nuxt-link>
</widgets-item-slider>
</div>
<div v-for="series in authorSeries" :key="series.id" class="py-4">
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
<h2 class="text-lg">{{ series.name }}</h2>
</nuxt-link>
<p class="text-white text-opacity-40 text-base px-2">Series</p>
</widgets-item-slider>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, app, params, redirect }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
console.error('Failed to get author', error)
return null
})
if (!author) {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
}
return {
author
}
},
data() {
return {}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryItems() {
return this.author.libraryItems || []
},
authorSeries() {
return this.author.series || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
editAuthor() {
this.$store.commit('globals/showEditAuthorModal', this.author)
},
authorUpdated(author) {
if (author.id === this.author.id) {
console.log('Author was updated', author)
this.author = {
...author,
series: this.authorSeries,
libraryItems: this.libraryItems
}
}
},
authorRemoved(author) {
if (author.id === this.author.id) {
console.warn('Author was removed')
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
}
}
},
mounted() {
if (!this.author) this.$router.replace('/')
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
},
beforeDestroy() {
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
}
}
</script>
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" /> <app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`"> <div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2"> <div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
@@ -15,7 +15,7 @@
<script> <script>
export default { export default {
asyncData({ store, redirect, route }) { asyncData({ store, redirect, route }) {
if (!store.getters['user/getIsRoot']) { if (!store.getters['user/getIsAdminOrUp']) {
// Non-Root user only has access to the listening stats page // Non-Root user only has access to the listening stats page
if (route.name !== 'config-stats') { if (route.name !== 'config-stats') {
redirect('/config/stats') redirect('/config/stats')
+23 -8
View File
@@ -41,7 +41,7 @@
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" /> <ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView"> <ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4 text-lg"> <p class="pl-4 text-lg">
Use alternative library bookshelf view Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -122,6 +122,20 @@
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Experimental Feature Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="tooltips.enableEReader">
<p class="pl-4 text-lg">
Enable e-reader for all users
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div> </div>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" /> <div class="h-0.5 bg-primary bg-opacity-30 w-full" />
@@ -169,10 +183,12 @@
<div> <div>
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" /> <ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="experimentalFeaturesTooltip"> <ui-tooltip :text="tooltips.experimentalFeatures">
<p class="pl-4 text-lg"> <p class="pl-4 text-lg">
Experimental Features Experimental Features
<span class="material-icons icon-text">info_outlined</span> <a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text">info_outlined</span>
</a>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -207,16 +223,18 @@ export default {
isPurgingCache: false, isPurgingCache: false,
newServerSettings: {}, newServerSettings: {},
tooltips: { tooltips: {
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart', scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names', scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names', scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"', scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"', sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time', scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers', bookshelfView: 'Alternative view without wooden bookshelf',
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept', storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension', storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers' coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
}, },
showConfirmPurgeCache: false showConfirmPurgeCache: false
} }
@@ -229,9 +247,6 @@ export default {
} }
}, },
computed: { computed: {
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
serverSettings() { serverSettings() {
return this.$store.state.serverSettings return this.$store.state.serverSettings
}, },
+13 -3
View File
@@ -1,16 +1,26 @@
<template> <template>
<div> <div>
<tables-library-libraries-table /> <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
showLibraryModal: false,
selectedLibrary: null
}
}, },
computed: {}, computed: {},
methods: {}, methods: {
setShowLibraryModal(selectedLibrary) {
this.selectedLibrary = selectedLibrary
this.showLibraryModal = true
}
},
mounted() {} mounted() {}
} }
</script> </script>
+6
View File
@@ -67,6 +67,12 @@
<script> <script>
export default { export default {
asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/config')
}
return {}
},
data() { data() {
return { return {
libraryStats: null libraryStats: null
-5
View File
@@ -34,11 +34,6 @@
<script> <script>
export default { export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() { data() {
return { return {
search: null, search: null,
+13 -6
View File
@@ -2,7 +2,7 @@
<div> <div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z" d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
@@ -15,7 +15,9 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span> <div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
@@ -23,15 +25,17 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span> <div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" /> <stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1> <h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p> <p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
@@ -52,6 +56,8 @@
</template> </template>
</div> </div>
</div> </div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</div> </div>
</template> </template>
@@ -59,7 +65,8 @@
export default { export default {
data() { data() {
return { return {
listeningStats: null listeningStats: null,
windowWidth: 0
} }
}, },
watch: { watch: {
+1 -4
View File
@@ -14,7 +14,7 @@
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)"> <div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
<p class="py-2 text-xs"> <p v-if="userToken" class="py-2 text-xs">
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span <strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span> ><span class="material-icons pl-2 text-base">content_copy</span>
</p> </p>
@@ -104,9 +104,6 @@ export default {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
username() { username() {
return this.user.username return this.user.username
}, },
+3
View File
@@ -5,6 +5,9 @@
<script> <script>
export default { export default {
asyncData({ redirect, store }) { asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/oops?message=No libraries')
}
redirect(`/library/${store.state.libraries.currentLibraryId}`) redirect(`/library/${store.state.libraries.currentLibraryId}`)
}, },
data() { data() {
+77 -13
View File
@@ -33,7 +33,7 @@
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p> <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl"> <p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
@@ -92,17 +92,20 @@
<!-- Alerts --> <!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center"> <div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span> <span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p> <p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
</div> </div>
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0"> <div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center"> <div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p> <p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
<span class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span> <span v-if="userIsAdminOrUp" class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div> </div>
</div> </div>
<!-- Podcast episodes currently downloading -->
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0"> <div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center"> <div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
@@ -133,7 +136,7 @@
{{ isMissing ? 'Missing' : 'Incomplete' }} {{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn> </ui-btn>
<ui-btn v-if="showExperimentalFeatures && ebookFile" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span> <span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read Read
</ui-btn> </ui-btn>
@@ -150,25 +153,40 @@
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" /> <ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="isPodcast" text="Find Episodes" direction="top"> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<!-- Experimental RSS feed open -->
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
</div> </div>
<div class="my-4 max-w-2xl"> <div class="my-4 max-w-2xl">
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p> <p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
</div> </div>
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :media="media" /> <div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">Invalid audio files</p>
<p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p>
</div>
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" /> <tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" /> <tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
</div> </div>
</div> </div>
</div> </div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" /> <modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
</div> </div>
</template> </template>
@@ -180,7 +198,7 @@ export default {
} }
// Include episode downloads for podcasts // Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => { var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -189,7 +207,8 @@ export default {
return redirect('/') return redirect('/')
} }
return { return {
libraryItem: item libraryItem: item,
rssFeedUrl: item.rssFeedUrl || null
} }
}, },
data() { data() {
@@ -200,10 +219,23 @@ export default {
showPodcastEpisodeFeed: false, showPodcastEpisodeFeed: false,
podcastFeedEpisodes: [], podcastFeedEpisodes: [],
episodesDownloading: [], episodesDownloading: [],
episodeDownloadsQueued: [] episodeDownloadsQueued: [],
showRssFeedModal: false
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
enableEReader() {
return this.$store.getters['getServerSetting']('enableEReader')
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isFile() {
return this.libraryItem.isFile
},
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['getServerSetting']('coverAspectRatio')
}, },
@@ -216,9 +248,6 @@ export default {
isDeveloperMode() { isDeveloperMode() {
return this.$store.state.developerMode return this.$store.state.developerMode
}, },
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isPodcast() { isPodcast() {
return this.libraryItem.mediaType === 'podcast' return this.libraryItem.mediaType === 'podcast'
}, },
@@ -228,11 +257,18 @@ export default {
isInvalid() { isInvalid() {
return this.libraryItem.isInvalid return this.libraryItem.isInvalid
}, },
invalidAudioFiles() {
if (this.isPodcast) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
},
showPlayButton() { showPlayButton() {
if (this.isMissing || this.isInvalid) return false if (this.isMissing || this.isInvalid) return false
if (this.isPodcast) return this.podcastEpisodes.length if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length return this.tracks.length
}, },
showReadButton() {
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
},
libraryId() { libraryId() {
return this.libraryItem.libraryId return this.libraryItem.libraryId
}, },
@@ -248,6 +284,9 @@ export default {
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
chapters() {
return this.media.chapters || []
},
tracks() { tracks() {
return this.media.tracks || [] return this.media.tracks || []
}, },
@@ -310,7 +349,7 @@ export default {
return this.media.ebookFile return this.media.ebookFile
}, },
showExperimentalReadAlert() { showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
}, },
description() { description() {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
@@ -349,6 +388,12 @@ export default {
}, },
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeedUrl
} }
}, },
methods: { methods: {
@@ -459,6 +504,9 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true) this.$store.commit('globals/setShowUserCollectionsModal', true)
}, },
clickRSSFeed() {
this.showRssFeedModal = true
},
episodeDownloadQueued(episodeDownload) { episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) { if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload) this.episodeDownloadsQueued.push(episodeDownload)
@@ -475,6 +523,18 @@ export default {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id) this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
} }
},
rssFeedOpen(data) {
if (data.libraryItemId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl
}
},
rssFeedClosed(data) {
if (data.libraryItemId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeedUrl = null
}
} }
}, },
mounted() { mounted() {
@@ -487,12 +547,16 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId) this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
} }
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
}, },
beforeDestroy() { beforeDestroy() {
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
+270
View File
@@ -0,0 +1,270 @@
<template>
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex justify-center mb-2">
<div class="w-full max-w-2xl">
<p class="text-xl">Metadata to embed</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(keyValue, index) in metadataKeyValues">
<div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ keyValue.key }}</div>
<div class="w-2/3">
{{ keyValue.value }}
</div>
</div>
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ chapter.start.toFixed(2) }}
</div>
<div class="w-24">
{{ chapter.end.toFixed(2) }}
</div>
</div>
</template>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div class="w-full flex justify-between items-center mb-4">
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
</div>
<div class="w-full mx-auto border border-opacity-10 bg-bg">
<div class="flex py-2 px-4">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-10">{{ file.index }}</div>
<div class="flex-grow">
{{ file.metadata.filename }}
</div>
<div class="w-16 font-mono text-gray-200">
{{ $bytesPretty(file.metadata.size) }}
</div>
<div class="w-24">
<div class="flex justify-center">
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
<div v-else-if="audiofilesEncoding[file.ino]">
<widgets-loading-spinner />
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getIsAdminOrUp']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!libraryItem) {
console.error('Not found...', params.id)
return redirect('/?error=not found')
}
if (libraryItem.mediaType !== 'book') {
console.error('Invalid media type')
return redirect('/?error=invalid media type')
}
if (!libraryItem.media.audioFiles.length) {
cnosole.error('No audio files')
return redirect('/?error=no audio files')
}
return {
libraryItem
}
},
data() {
return {
audiofilesEncoding: {},
audiofilesFinished: {},
updatingMetadata: false,
embedFinished: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
audioFiles() {
return this.media.audioFiles || []
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
metadataKeyValues() {
const keyValues = [
{
key: 'title',
value: this.mediaMetadata.title
},
{
key: 'artist',
value: this.mediaMetadata.authorName
},
{
key: 'album_artist',
value: this.mediaMetadata.authorName
},
{
key: 'date',
value: this.mediaMetadata.publishedYear
},
{
key: 'description',
value: this.mediaMetadata.description
},
{
key: 'genre',
value: this.mediaMetadata.genres.join(';')
},
{
key: 'performer',
value: this.mediaMetadata.narratorName
}
]
if (this.mediaMetadata.subtitle) {
keyValues.push({
key: 'subtitle',
value: this.mediaMetadata.subtitle
})
}
if (this.mediaMetadata.asin) {
keyValues.push({
key: 'asin',
value: this.mediaMetadata.asin
})
}
if (this.mediaMetadata.isbn) {
keyValues.push({
key: 'isbn',
value: this.mediaMetadata.isbn
})
}
if (this.mediaMetadata.language) {
keyValues.push({
key: 'language',
value: this.mediaMetadata.language
})
}
if (this.mediaMetadata.series.length) {
var firstSeries = this.mediaMetadata.series[0]
keyValues.push({
key: 'series',
value: firstSeries.name
})
if (firstSeries.sequence) {
keyValues.push({
key: 'series-part',
value: firstSeries.sequence
})
}
}
return keyValues
},
metadataChapters() {
var chapters = this.media.chapters || []
return chapters.concat(chapters)
}
},
methods: {
updateAudioFileMetadata() {
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
this.updatingMetadata = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
this.updatingMetadata = false
})
}
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
this.updatingMetadata = true
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.updatingMetadata = false
this.embedFinished = true
this.audiofilesEncoding = {}
this.$toast.success('Audio file metadata updated')
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true)
},
audiofileMetadataFinished(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, false)
this.$set(this.audiofilesFinished, data.ino, true)
}
},
mounted() {
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
},
beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}
}
</script>
+12 -20
View File
@@ -1,21 +1,13 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home />
<app-side-rail class="hidden md:block" /> <div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
<div class="flex-grow"> <div class="flex flex-wrap justify-center">
<app-book-shelf-toolbar is-home /> <template v-for="author in authors">
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto"> <cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
<div class="flex flex-wrap justify-center"> </template>
<template v-for="author in authors">
<nuxt-link :key="author.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`">
<cards-author-card :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
</nuxt-link>
</template>
</div>
</div>
</div> </div>
</div> </div>
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
</div> </div>
</template> </template>
@@ -40,9 +32,7 @@ export default {
data() { data() {
return { return {
loading: true, loading: true,
authors: [], authors: []
showAuthorModal: false,
selectedAuthor: null
} }
}, },
computed: { computed: {
@@ -51,6 +41,9 @@ export default {
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
},
selectedAuthor() {
return this.$store.state.globals.selectedAuthor
} }
}, },
methods: { methods: {
@@ -68,7 +61,7 @@ export default {
}, },
authorUpdated(author) { authorUpdated(author) {
if (this.selectedAuthor && this.selectedAuthor.id === author.id) { if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
this.selectedAuthor = author this.$store.commit('globals/setSelectedAuthor', author)
} }
this.authors = this.authors.map((au) => { this.authors = this.authors.map((au) => {
if (au.id === author.id) { if (au.id === author.id) {
@@ -81,8 +74,7 @@ export default {
this.authors = this.authors.filter((au) => au.id !== author.id) this.authors = this.authors.filter((au) => au.id !== author.id)
}, },
editAuthor(author) { editAuthor(author) {
this.selectedAuthor = author this.$store.commit('globals/showEditAuthorModal', author)
this.showAuthorModal = true
} }
}, },
mounted() { mounted() {
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-side-rail class="hidden md:block" /> <app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
<div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
</div>
</div>
</div> </div>
</template> </template>
+2 -7
View File
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home />
<app-side-rail class="hidden md:block" /> <app-book-shelf-categorized />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div> </div>
</template> </template>
@@ -1,38 +1,33 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar page="podcast-search" />
<app-side-rail class="hidden md:block" /> <div class="w-full h-full overflow-y-auto p-12 relative">
<div class="flex-grow"> <div class="w-full max-w-3xl mx-auto">
<app-book-shelf-toolbar page="podcast-search" /> <form @submit.prevent="submit" class="flex">
<div class="w-full h-full overflow-y-auto p-12 relative"> <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
<div class="w-full max-w-3xl mx-auto"> <ui-btn type="submit" :disabled="processing">Submit</ui-btn>
<form @submit.prevent="submit" class="flex"> </form>
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" /> </div>
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
</form>
</div>
<div class="w-full max-w-3xl mx-auto py-4"> <div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)"> <div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary"> <div class="w-24 min-w-24 h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" /> <img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div> </div>
<div class="flex-grow pl-4 max-w-2xl"> <div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a> <a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p> <p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div> </div>
</div>
</template>
</div> </div>
</template>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40"> <div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
<ui-loading-indicator /> <ui-loading-indicator />
</div>
</div>
</div> </div>
</div> </div>
+4 -18
View File
@@ -1,17 +1,9 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home page="search" :search-query="query" />
<app-side-rail class="hidden md:block" /> <app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div class="flex-grow"> <div v-else class="w-full py-16">
<app-book-shelf-toolbar is-home page="search" :search-query="query" /> <p class="text-xl text-center">No Search results for "{{ query }}"</p>
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
<div class="flex justify-center">
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -79,12 +71,6 @@ export default {
this.$refs.bookshelf.setShelvesFromSearch() this.$refs.bookshelf.setShelvesFromSearch()
} }
}) })
},
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
} }
}, },
mounted() {}, mounted() {},
+4 -9
View File
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar :selected-series="series" />
<app-side-rail class="hidden md:block" /> <app-lazy-bookshelf page="series-books" :series-id="seriesId" />
<div class="flex-grow">
<app-book-shelf-toolbar :selected-series="series" />
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
</div>
</div>
</div> </div>
</template> </template>
@@ -24,7 +19,7 @@ export default {
return redirect(`/library/${libraryId}`) return redirect(`/library/${libraryId}`)
} }
var series = await app.$axios.$get(`/api/series/${params.id}`).catch((error) => { var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -33,7 +28,7 @@ export default {
} }
return { return {
series: series.name, series,
seriesId: params.id seriesId: params.id
} }
}, },
+137 -31
View File
@@ -1,7 +1,29 @@
<template> <template>
<div class="w-full h-screen bg-bg"> <div class="w-full h-screen bg-bg">
<div class="w-full flex h-1/2 items-center justify-center"> <div class="w-full flex h-full items-center justify-center">
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4"> <div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
<p class="text-center text-lg font-semibold">Server could not be reached</p>
</div>
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
</div>
</form>
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">Login</p> <p class="text-3xl text-white text-center mb-4">Login</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" /> <div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <p v-if="error" class="text-error text-center py-2">{{ error }}</p>
@@ -11,8 +33,8 @@
<label class="text-xs text-gray-300 uppercase">Password</label> <label class="text-xs text-gray-300 uppercase">Password</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" /> <ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end"> <div class="w-full flex justify-end py-3">
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button> <ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
</div> </div>
</form> </form>
</div> </div>
@@ -26,15 +48,33 @@ export default {
data() { data() {
return { return {
error: null, error: null,
criticalError: null,
processing: false, processing: false,
username: '', username: '',
password: null password: null,
showInitScreen: false,
isInit: false,
newRoot: {
username: 'root',
password: ''
},
confirmPassword: '',
ConfigPath: '',
MetadataPath: ''
} }
}, },
watch: { watch: {
user(newVal) { user(newVal) {
if (newVal) { if (newVal) {
if (this.$route.query.redirect) { if (!this.$store.state.libraries.currentLibraryId) {
// No libraries available to this user
if (this.$store.getters['user/getIsRoot']) {
// If root user go to config/libraries
this.$router.replace('/config/libraries')
} else {
this.$router.replace('/oops?message=No libraries available')
}
} else if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`) this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
@@ -48,8 +88,52 @@ export default {
} }
}, },
methods: { methods: {
setUser(user, defaultLibraryId) { async submitServerSetup() {
this.$store.commit('libraries/setCurrentLibrary', defaultLibraryId) if (!this.newRoot.username || !this.newRoot.username.trim()) {
this.$toast.error('Must enter a root username')
return
}
if (this.newRoot.password !== this.confirmPassword) {
this.$toast.error('Password mismatch')
return
}
if (!this.newRoot.password) {
if (!confirm('Are you sure you want to create the root user with no password?')) {
return
}
}
this.processing = true
const payload = {
newRoot: { ...this.newRoot }
}
var success = await this.$axios
.$post('/init', payload)
.then(() => true)
.catch((error) => {
console.error('Failed', error.response)
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
return false
})
if (!success) {
this.processing = false
return
}
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
require('@/plugins/chromecast.js').default(this)
}
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
}, },
async submitForm() { async submitForm() {
@@ -69,37 +153,59 @@ export default {
if (authRes && authRes.error) { if (authRes && authRes.error) {
this.error = authRes.error this.error = authRes.error
} else if (authRes) { } else if (authRes) {
this.setUser(authRes.user, authRes.userDefaultLibraryId) this.setUser(authRes)
} }
this.processing = false this.processing = false
}, },
checkAuth() { checkAuth() {
if (localStorage.getItem('token')) { var token = localStorage.getItem('token')
var token = localStorage.getItem('token') if (!token) return false
if (token) { this.processing = true
this.processing = true
this.$axios return this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}) })
.then((res) => { .then((res) => {
this.setUser(res.user, res.userDefaultLibraryId) this.setUser(res)
this.processing = false this.processing = false
}) return true
.catch((error) => { })
console.error('Authorize error', error) .catch((error) => {
this.processing = false console.error('Authorize error', error)
}) this.processing = false
} return false
} })
},
checkStatus() {
this.processing = true
this.$axios
.$get('/status')
.then((res) => {
this.processing = false
this.isInit = res.isInit
this.showInitScreen = !res.isInit
if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || ''
}
})
.catch((error) => {
console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed'
})
} }
}, },
mounted() { async mounted() {
this.checkAuth() if (localStorage.getItem('token')) {
var userfound = await this.checkAuth()
if (userfound) return // if valid user no need to check status
}
this.checkStatus()
} }
} }
</script> </script>
+11 -3
View File
@@ -53,8 +53,8 @@
</widgets-alert> </widgets-alert>
<!-- Item Upload cards --> <!-- Item Upload cards -->
<template v-for="(item, index) in items"> <template v-for="item in items">
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" /> <cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template> </template>
<!-- Upload/Reset btns --> <!-- Upload/Reset btns -->
@@ -86,6 +86,13 @@ export default {
uploadFinished: false uploadFinished: false
} }
}, },
watch: {
selectedLibrary(newVal) {
if (newVal && !this.selectedFolderId) {
this.setDefaultFolder()
}
}
},
computed: { computed: {
inputAccept() { inputAccept() {
var extensions = [] var extensions = []
@@ -195,7 +202,8 @@ export default {
e.preventDefault() e.preventDefault()
this.isDragging = false this.isDragging = false
var items = e.dataTransfer.items || [] var items = e.dataTransfer.items || []
var itemResults = await this.uploadHelpers.getItemsFromDrop(items)
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
this.setResults(itemResults) this.setResults(itemResults)
}, },
inputChanged(e) { inputChanged(e) {
+3
View File
@@ -179,6 +179,9 @@ export default class PlayerHandler {
} }
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady) this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
// browser media session api
this.ctx.setMediaSession()
} }
closePlayer() { closePlayer() {
+2 -1
View File
@@ -21,7 +21,8 @@ const BookCoverAspectRatio = {
const BookshelfView = { const BookshelfView = {
STANDARD: 0, STANDARD: 0,
TITLES: 1 TITLES: 1,
AUTHOR: 2 // Books shown on author page
} }
const PlayMethod = { const PlayMethod = {
+59 -15
View File
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
if (!date || !isDate(date)) return null if (!date || !isDate(date)) return null
return date return date
} }
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
var date = addDays(jsdate, daysToAdd)
if (!date || !isDate(date)) return null
return date
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) { if (isNaN(bytes) || bytes == 0) {
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
Vue.prototype.$elapsedPretty = (seconds) => { Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
if (seconds < 60) {
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
}
var minutes = Math.floor(seconds / 60) var minutes = Math.floor(seconds / 60)
if (minutes < 70) { if (minutes < 70) {
return `${minutes} min` return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
} }
var hours = Math.floor(minutes / 60) var hours = Math.floor(minutes / 60)
minutes -= hours * 60 minutes -= hours * 60
if (!minutes) { if (!minutes) {
return `${hours} hr` return `${hours} ${useFullNames ? 'hours' : 'hr'}`
} }
return `${hours} hr ${minutes} min` return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
} }
Vue.prototype.$secondsToTimestamp = (seconds) => { Vue.prototype.$secondsToTimestamp = (seconds) => {
@@ -106,10 +114,11 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
} }
} }
Vue.prototype.$sanitizeFilename = (input, replacement = '') => { Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof input !== 'string') {
return false return false
} }
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g; var illegalRe = /[\/\?<>\\:\*\|"]/g;
var controlRe = /[\x00-\x1f\x80-\x9f]/g; var controlRe = /[\x00-\x1f\x80-\x9f]/g;
var reservedRe = /^\.+$/; var reservedRe = /^\.+$/;
@@ -117,6 +126,7 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
var windowsTrailingRe = /[\. ]+$/; var windowsTrailingRe = /[\. ]+$/;
var sanitized = input var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
.replace(reservedRe, replacement) .replace(reservedRe, replacement)
@@ -125,20 +135,54 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
return sanitized return sanitized
} }
// SOURCE: https://gist.github.com/spyesx/561b1d65d4afb595f295
// modified: allowed underscores
Vue.prototype.$sanitizeSlug = (str) => {
if (!str) return ''
str = str.replace(/^\s+|\s+$/g, '') // trim
str = str.toLowerCase()
// remove accents, swap ñ for n, etc
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
for (var i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
str = str.replace('.', '-') // replace a dot by a dash
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes
.replace(/\//g, '') // collapse all forward-slashes
return str
}
Vue.prototype.$copyToClipboard = (str, ctx) => { Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!navigator.clipboard) { if (!navigator.clipboard) {
console.warn('Clipboard not supported') navigator.clipboard.writeText(str).then(() => {
return resolve(false) if (ctx) ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => {
console.error('Clipboard copy failed', str, err)
resolve(false)
})
} else {
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if (ctx) ctx.$toast.success('Copied to clipboard')
} }
navigator.clipboard.writeText(str).then(() => {
console.log('Clipboard copy success', str)
ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => {
console.error('Clipboard copy failed', str, err)
resolve(false)
})
}) })
} }

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