Compare commits

...

309 Commits

Author SHA1 Message Date
advplyr
d38d4dcd62 Version bump 1.6.0 2021-10-31 15:37:53 -05:00
advplyr
724aa67cf4 Add: Bookshelf list view no longer experimental 2021-10-31 15:35:13 -05:00
advplyr
5a0eb6d52b Add: Bookmarks no longer experimental 2021-10-31 15:33:27 -05:00
advplyr
bf9cb5ddec Fix: List view overflowing titles #149 2021-10-31 15:31:03 -05:00
advplyr
e073577574 Add: Support for multiple authors separated by & #160 2021-10-31 14:59:39 -05:00
advplyr
94f47b855b Fix: docker compose example 2021-10-31 09:58:30 -05:00
advplyr
8a684ccdc4 Add: Docker compose example and readme #164 2021-10-31 09:54:58 -05:00
advplyr
e564c80ed2 Add: Bookshelf list link to audiobook page 2021-10-30 20:12:36 -05:00
advplyr
729654f5b2 Add: Experimental list view #149 2021-10-30 18:50:49 -05:00
advplyr
6fd3317454 Add: experimental library list view #149, Add: experimental series fan out on hover #149 2021-10-29 20:42:28 -05:00
advplyr
9057afb5ee Version update 2021-10-28 19:39:51 -05:00
advplyr
7933eb369e Change: Series show first cover on top #149 2021-10-28 19:34:29 -05:00
advplyr
866ee18016 Fix: backups toast message on successful backup #101 2021-10-28 17:54:53 -05:00
advplyr
ff92fbb849 Change: AudiobookProgress becomes UserAudiobookData, Change: Delete audiobook progress route 2021-10-28 17:19:09 -05:00
advplyr
ad4dad1c29 Add: experimental match tab with google books search #59, Add: isbn field for books #59 2021-10-28 14:41:42 -05:00
advplyr
7c1789a7c2 Fix: filter out invalid bookmarks 2021-10-28 06:35:22 -05:00
advplyr
b6ae6d86fa Version update 2021-10-26 20:09:35 -05:00
advplyr
9f66054a72 Add: Experimental bookmarks edit and delete #115 2021-10-26 20:09:04 -05:00
advplyr
fffa02e7e8 Change: Book details modal re-scan and save metadata buttons shifted right #147 2021-10-26 18:35:37 -05:00
advplyr
6fbd9dc260 Fix: initializing backup settings #146 2021-10-26 18:29:04 -05:00
advplyr
c6614aba05 Fix: listen to changing series query string #140 2021-10-26 18:24:42 -05:00
advplyr
ee62385980 Change: audio player author and duration styles #122 2021-10-26 18:12:19 -05:00
advplyr
806017175d Fix: seconds to timestamp floor #144 2021-10-26 17:55:48 -05:00
advplyr
1a3a7c5823 Version update 2021-10-26 17:38:02 -05:00
advplyr
550873ff87 Merge pull request #150 from svdztn/seekback-support
Seekback support
2021-10-26 16:53:06 -05:00
advplyr
4dd9f779e2 Fix check user audiobook data exists when getting last update 2021-10-26 16:52:45 -05:00
svd
580b961c4a log modify 2021-10-26 11:36:23 +08:00
svdztn
28b1132171 Merge branch 'advplyr:master' into seekback-support 2021-10-26 10:53:01 +08:00
advplyr
8b31c6555a Fix: audiobook progress emitter emit to all user sockets #145, Fix: save user after progress update 2021-10-25 21:14:54 -05:00
svd
8ffb4f88c9 transcoding maxSeekBackTime more; json add lastUpdate 2021-10-26 09:38:09 +08:00
advplyr
c5eafdfa8a Change: audio player shows time remaining and percent #122, Add: experimental bookmarks start #115 2021-10-24 18:25:44 -05:00
advplyr
f9bf846b30 readme and version update 2021-10-24 16:05:58 -05:00
advplyr
335bbac81d Add: Filter and side rail button to show books with issues #132, Add: realtime updates for audiobook progress 2021-10-24 15:53:51 -05:00
advplyr
874c910e24 Fix: hotkeys prevent default browser behavior #135, Fix: mark audiobook as read when less than 10s remaining 2021-10-24 14:02:49 -05:00
advplyr
aca88f73ad Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2021-10-24 11:32:59 -05:00
advplyr
ed80e15b7d Fix: stream to use actual timestamp, update start time to tenth of second string #116 2021-10-24 11:32:52 -05:00
advplyr
3f13d35241 Merge pull request #136 from zv0n/master
Updated Readme to include Apache config.
2021-10-24 06:29:22 -05:00
zvon
05be496817 Updated Readme to include Apache config. 2021-10-24 10:36:34 +02:00
advplyr
1cca288031 Version update 2021-10-23 21:09:23 -05:00
advplyr
d05e9ebfdd Merge pull request #131 from jarrodCoombes/master
Updated Readme to include nginx config.
2021-10-23 20:32:07 -05:00
advplyr
92c2c53c09 Add: track manager sort by filename #128, Add: Support publish year wrapped in parenthesis, Change: Parse out CD Num from filename, Change: Show First Last author everywhere when Last, First is used in folder name, Fix: Re-Scan updates parsed track nums 2021-10-23 20:31:48 -05:00
JRC
80f8a784c8 Fixed minor formatting issue. 2021-10-23 17:13:29 -07:00
JRC
de9c0ef034 Updated the readme to include the nginx settings 2021-10-23 17:12:32 -07:00
advplyr
b32b99418a Add: map series and series-part meta tags #129 2021-10-23 17:32:42 -05:00
advplyr
98c1ee01fd Add: add hotkeys to modals, player, and ereader #121, Fix: audio player track not resizing on window resize #123, Add: sortable columns on manage tracks page #128 2021-10-23 16:49:34 -05:00
advplyr
97a065030e Change: ffmpeg stream avoid negative timestamp #116 2021-10-23 13:42:07 -05:00
advplyr
ee1dc92898 Fix: hls fmp4 init.mp4 filename to stream flac files 2021-10-23 10:57:01 -05:00
advplyr
74d2987310 Merge pull request #124 from zv0n/master
Change: Dockerfile install ffmpeg from source to support more architectures
2021-10-23 10:52:27 -05:00
advplyr
c20aaf3cb2 Fix: genre tag map check empty array and check metadata on rescan #114 2021-10-23 06:50:13 -05:00
zvon
994eb2862e Change: support more architectures in Dockerfile 2021-10-23 13:18:58 +02:00
advplyr
ff1eeda468 Change: config page to multiple pages, Add: user permissions for accessible libraries #120, Add: map genre metadata tag #114, Add: experimental audio player keyboard controls #121, Add: view user audiobook progress list 2021-10-22 20:08:02 -05:00
advplyr
7d9ed75a28 Change: audio player default volume to 100% #118, Change: username case insensitive #117, Fix: allowing multiple users of the same name, Added: experimental scan audio tracks show raw tags #114 2021-10-20 18:54:05 -05:00
advplyr
09aed354b3 Fix: icon font size 2021-10-18 19:53:33 -05:00
advplyr
e3425acd75 Fix icon size issue, clean up reader interface, extract metadata from comics #113 2021-10-17 18:05:43 -05:00
advplyr
03e39640be Fix series displayed on global search menu book 2021-10-17 12:18:26 -05:00
advplyr
48f0e039e5 New search page, updated search menu includes tags #112 2021-10-17 11:29:52 -05:00
advplyr
aa872948d5 Fonts saved locally #111, Fix select cover from search 2021-10-17 07:09:40 -05:00
advplyr
88e2bac3f5 Support cbr and cbz comics and comic reader #109 2021-10-16 20:33:49 -05:00
advplyr
18c1d8f1a3 Add: user settings for mobile sort & filter 2021-10-16 15:49:34 -05:00
advplyr
b9dee8704f add db init logs for checking root user #81, add track number check on edit page #108 2021-10-16 07:49:12 -05:00
advplyr
03963aa9a1 change color of book read icon #105, basic .pdf reader #107, fix: cover path updating properly #102, step forward/backward from book edit modal #100, add all files tab to edit modal #99, select input auto submit on blur #98 2021-10-15 20:31:00 -05:00
advplyr
315592efe5 Backup manager to only zip specific directories in config #89 2021-10-14 19:22:59 -05:00
advplyr
cd6e99b4c3 Update users table info #94, Reorder libraries in config #95, Use dropdown for library menu #96, update mobi reader 2021-10-12 20:07:42 -05:00
advplyr
9715c53332 Fix: root user password change #93, Change: link to series filter on ab page #90, Add: basic mobi and azw3 ereader support 2021-10-11 16:59:41 -05:00
advplyr
120c70622a Fix folder browser, fix track number parsed from filename too large, add mp4 audiobook support 2021-10-10 19:29:22 -05:00
advplyr
04f92c33c2 Abort backup if it is getting too large #89, support for ebook only book folders #75 2021-10-10 16:36:21 -05:00
advplyr
0c168b3da4 New screenshot 2021-10-09 14:54:45 -05:00
advplyr
2ed0468b33 Banner3 2021-10-09 13:17:07 -05:00
advplyr
d9c8aa287d Banner 2 2021-10-09 13:14:41 -05:00
advplyr
1d118d1364 Banner 2021-10-09 13:13:34 -05:00
advplyr
c94d9e620c Readme 2021-10-09 13:06:10 -05:00
advplyr
ff68440d26 Readme 2021-10-09 12:19:29 -05:00
advplyr
125a8a8e32 Readme 2021-10-09 12:18:57 -05:00
advplyr
2d507a455e Readme 2021-10-09 12:18:31 -05:00
advplyr
32bc9d5282 Update global search, fix toggling between automated backup, add open search cover in new tab #83 2021-10-09 11:09:06 -05:00
advplyr
59d12ef5de Remove Match Tab 2021-10-08 17:51:09 -05:00
advplyr
e80ec10e8a Fix ebook url #75, download other files #75, fix book icon disappearing #88, backups #87 2021-10-08 17:30:20 -05:00
advplyr
f752c19418 narrator filter, no series filter, full paths toggle, book landing page details, new sans font, update query string on filter/sort, persist experimental feature flag, batch edit redirect bug, upload file permissions and owner 2021-10-06 21:08:52 -05:00
advplyr
75aede914f Fix URL encoding, fix download m4b cover art 2021-10-06 13:00:12 -05:00
advplyr
19dcb6173e Fix download requests, v1.4.0 rollout 2021-10-06 07:23:32 -05:00
advplyr
d9d34e87e0 Support multi library 1.4.0 2021-10-05 21:10:49 -05:00
advplyr
a65f7e6fad Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2021-10-04 22:11:50 -05:00
advplyr
577f3bead9 Support for libraries and folder mapping, updating static cover path, detect reader.txt 2021-10-04 22:11:42 -05:00
advplyr
49072769a5 merge
Merge branch 'master' of https://github.com/advplyr/audiobookshelf
2021-10-03 19:41:53 +00:00
advplyr
a590e795e3 Fix ebook url, add opus to uploader, set chapters after track updates 2021-10-02 15:36:33 -05:00
advplyr
b20cbb020a Fix ebook url, add opus to uploader, set chapters after track updates 2021-10-02 15:36:33 -05:00
advplyr
1637b6c7f0 merge 2021-10-02 13:55:40 -05:00
advplyr
78449d918b merge 2021-10-02 13:55:40 -05:00
advplyr
bd336345ee Support for opus audio type, experimental features toggle, epub reader starting point 2021-10-02 13:50:39 -05:00
advplyr
326663d9d9 Support for opus audio type, experimental features toggle, epub reader starting point 2021-10-02 13:50:39 -05:00
advplyr
ef2b9a0415 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-02 11:27:13 +00:00
advplyr
509d9e5e03 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-02 11:27:13 +00:00
Mark Cooper
3dd8dc6dd4 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-01 20:29:00 -05:00
advplyr
a4bf3a759f Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-01 20:29:00 -05:00
Mark Cooper
8d9d5a8d1b Always sync file inodes, save http url covers in cover directory 2021-10-01 18:42:48 -05:00
advplyr
28cbe0a95c Always sync file inodes, save http url covers in cover directory 2021-10-01 18:42:48 -05:00
Mark Cooper
0db34dcab5 Scanner update check for mismatched inode and update 2021-10-01 14:52:10 -05:00
advplyr
4e45ff83c6 Scanner update check for mismatched inode and update 2021-10-01 14:52:10 -05:00
Mark Cooper
47c6c1aaad Readme update 2021-09-30 19:57:13 -05:00
advplyr
d753683090 Readme update 2021-09-30 19:57:13 -05:00
Mark Cooper
d6cab8e591 logLevel as server setting, logger config page, re-scan audiobook option, fix embedded cover extraction, flac and mobi support, fix series bookshelf not wrapping 2021-09-30 18:52:32 -05:00
advplyr
de5baba930 logLevel as server setting, logger config page, re-scan audiobook option, fix embedded cover extraction, flac and mobi support, fix series bookshelf not wrapping 2021-09-30 18:52:32 -05:00
Mark Cooper
dc18eb408e Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used 2021-09-29 20:43:36 -05:00
advplyr
b26c1ba886 Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used 2021-09-29 20:43:36 -05:00
Mark Cooper
6f891208d0 Update readme 2021-09-29 11:04:35 -05:00
advplyr
b74b12301c Update readme 2021-09-29 11:04:35 -05:00
Mark Cooper
643040e635 Readme update 2021-09-29 10:52:59 -05:00
advplyr
bb7e580ee3 Readme update 2021-09-29 10:52:59 -05:00
Mark Cooper
4c07f9ec25 Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
advplyr
c82d888b5a Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
Mark Cooper
0ba38d45bc Readme update 2021-09-28 17:59:44 -05:00
advplyr
07207b091d Readme update 2021-09-28 17:59:44 -05:00
Mark Cooper
0da327222e Fix config page not scrolling, add scroll arrows on home page, fix routing issue on back button, fix continue reading shelf 2021-09-28 17:36:41 -05:00
advplyr
8dc0acb6b7 Fix config page not scrolling, add scroll arrows on home page, fix routing issue on back button, fix continue reading shelf 2021-09-28 17:36:41 -05:00
Mark Cooper
868e1af28a Starting point for home page 2021-09-28 06:44:40 -05:00
advplyr
8389a31775 Starting point for home page 2021-09-28 06:44:40 -05:00
Mark Cooper
a343a1038c Remove ino from file tables 2021-09-27 07:02:31 -05:00
advplyr
893032bcc0 Remove ino from file tables 2021-09-27 07:02:31 -05:00
Mark Cooper
3e5338ec8e Fixing scanner inodes, select all fix, starting ebook reader 2021-09-27 06:52:21 -05:00
advplyr
26b37def46 Fixing scanner inodes, select all fix, starting ebook reader 2021-09-27 06:52:21 -05:00
Mark Cooper
01fdca4bf9 Fix docs link url 2021-09-26 17:21:10 -05:00
advplyr
29ba62357d Fix docs link url 2021-09-26 17:21:10 -05:00
Mark Cooper
ed96dd7c81 Readme update docs 2021-09-26 17:20:41 -05:00
advplyr
8ddfc6e7ac Readme update docs 2021-09-26 17:20:41 -05:00
Mark Cooper
1ead5de9f5 Hide volume number on selection mode 2021-09-26 15:44:25 -05:00
advplyr
ef8ec3d7ab Hide volume number on selection mode 2021-09-26 15:44:25 -05:00
Mark Cooper
06554811e2 Series order by volume number, show volume number, keyword filter, fix overflow bug 2021-09-26 15:34:08 -05:00
advplyr
68f534a97e Series order by volume number, show volume number, keyword filter, fix overflow bug 2021-09-26 15:34:08 -05:00
Mark Cooper
9935bd2ffa Readme audiobookshelf.org 2021-09-26 12:08:48 -05:00
advplyr
0d556c3f76 Readme audiobookshelf.org 2021-09-26 12:08:48 -05:00
Mark Cooper
4260903bbe Readme update install instructions 2021-09-25 17:58:06 -05:00
advplyr
8c91ef85c9 Readme update install instructions 2021-09-25 17:58:06 -05:00
Mark Cooper
64ae8ef849 linux installer use existing config 2021-09-25 17:37:21 -05:00
advplyr
ac950f2173 linux installer use existing config 2021-09-25 17:37:21 -05:00
Mark Cooper
be15f2f5a0 Fix installer 2021-09-25 17:14:06 -05:00
advplyr
bf1e6051be Fix installer 2021-09-25 17:14:06 -05:00
Mark Cooper
341de6a196 Fix linuxpackager control file 2021-09-25 17:10:02 -05:00
advplyr
0eb00c8820 Fix linuxpackager control file 2021-09-25 17:10:02 -05:00
Mark Cooper
740f6966ba Linuxpackager create debian control file 2021-09-25 17:07:48 -05:00
advplyr
6067aecc4f Linuxpackager create debian control file 2021-09-25 17:07:48 -05:00
Mark Cooper
2e889ff9fe Increment version, linuxpacakger executable permissions 2021-09-25 16:45:53 -05:00
advplyr
f0b070ca1b Increment version, linuxpacakger executable permissions 2021-09-25 16:45:53 -05:00
Mark Cooper
d123abd4cd Linuxpackager to use /dist directory 2021-09-25 16:34:36 -05:00
advplyr
34f0815319 Linuxpackager to use /dist directory 2021-09-25 16:34:36 -05:00
Mark Cooper
cc84349d6d Adding linux and ppa install to readme 2021-09-25 16:23:57 -05:00
advplyr
2ed2790550 Adding linux and ppa install to readme 2021-09-25 16:23:57 -05:00
Mark Cooper
b5e83d8866 Preinstall prompt more readable 2021-09-25 14:14:22 -05:00
advplyr
e2a6bf6c1a Preinstall prompt more readable 2021-09-25 14:14:22 -05:00
Mark Cooper
ca9521ac9a Debian package remove conf file, generate in preinst now 2021-09-25 14:05:27 -05:00
advplyr
cce860ff74 Debian package remove conf file, generate in preinst now 2021-09-25 14:05:27 -05:00
Mark Cooper
078f404fe4 Linux builder bash script 2021-09-25 14:02:50 -05:00
advplyr
73ef4cd4b5 Linux builder bash script 2021-09-25 14:02:50 -05:00
Mark Cooper
bc47dfa343 Debian preinstall script for config and ffmpeg 2021-09-25 13:01:53 -05:00
advplyr
d95689a078 Debian preinstall script for config and ffmpeg 2021-09-25 13:01:53 -05:00
Mark Cooper
03f39d71e3 Fix check old streams in metadata, download manager worker thread path 2021-09-25 10:35:33 -05:00
advplyr
784ffbc568 Fix check old streams in metadata, download manager worker thread path 2021-09-25 10:35:33 -05:00
Mark Cooper
69cd6aa4d0 Update debian build 2021-09-24 20:18:02 -05:00
advplyr
77eee0c85a Update debian build 2021-09-24 20:18:02 -05:00
Mark Cooper
e161f70710 Debian package builder 2021-09-24 19:37:35 -05:00
advplyr
0d33ed98d9 Debian package builder 2021-09-24 19:37:35 -05:00
Mark Cooper
3ef0173226 build script permissions workaround 2021-09-24 17:38:35 -05:00
advplyr
4de3ffac63 build script permissions workaround 2021-09-24 17:38:35 -05:00
Mark Cooper
87a308f749 Fix build script 2021-09-24 17:26:10 -05:00
advplyr
be6da8dad9 Fix build script 2021-09-24 17:26:10 -05:00
Mark Cooper
f45a1c4161 Ignore dist dir 2021-09-24 17:23:40 -05:00
advplyr
ec23250273 Ignore dist dir 2021-09-24 17:23:40 -05:00
Mark Cooper
5e15695f64 Build scripts 2021-09-24 17:21:47 -05:00
advplyr
8fdbe58da8 Build scripts 2021-09-24 17:21:47 -05:00
Mark Cooper
b35997e8be Update chapters modal, search page, fix version check, ignore matching audio file paths on rescan 2021-09-24 16:14:33 -05:00
advplyr
e1fd74caaf Update chapters modal, search page, fix version check, ignore matching audio file paths on rescan 2021-09-24 16:14:33 -05:00
Mark Cooper
fcd664c16e Side rail, book group cards, fix dropdown select 2021-09-24 07:32:38 -05:00
advplyr
37c38e69df Side rail, book group cards, fix dropdown select 2021-09-24 07:32:38 -05:00
Mark Cooper
94741598af Pkg scripts win/linux 2021-09-22 20:40:35 -05:00
advplyr
31e109d0f0 Pkg scripts win/linux 2021-09-22 20:40:35 -05:00
Mark Cooper
6cb418a871 Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata 2021-09-21 20:57:33 -05:00
advplyr
b23f9362ef Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata 2021-09-21 20:57:33 -05:00
Mark Cooper
d234fdea11 Fix player track tooltip overflowing page 2021-09-21 17:28:43 -05:00
advplyr
ae1b94e991 Fix player track tooltip overflowing page 2021-09-21 17:28:43 -05:00
Mark Cooper
13ac5f1d2a Fix package.json script 2021-09-21 16:55:32 -05:00
advplyr
03feaf6e5a Fix package.json script 2021-09-21 16:55:32 -05:00
Mark Cooper
f4d6e65380 Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
advplyr
c821ef424f Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
Mark Cooper
baccbaf82a Remove production from prod script 2021-09-19 20:38:24 -05:00
advplyr
9e668bbf35 Remove production from prod script 2021-09-19 20:38:24 -05:00
Mark Cooper
d6969e0b85 Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
advplyr
9e8ea43d5b Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
Mark Cooper
b3ad9c95ce Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
advplyr
c7b01165fd Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
Mark Cooper
2f6417dec2 Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
advplyr
a63d76985c Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
Mark Cooper
f54d48270e Update regex for volume scanner 2021-09-18 13:26:02 -05:00
advplyr
ea61400329 Update regex for volume scanner 2021-09-18 13:26:02 -05:00
Mark Cooper
57321084af Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
advplyr
3ba3069894 Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
Mark Cooper
1d97422011 Readme update 2021-09-18 12:53:52 -05:00
advplyr
6738c51f6e Readme update 2021-09-18 12:53:52 -05:00
Mark Cooper
2f2a64b89e Readme update 2021-09-18 12:53:14 -05:00
advplyr
ef587a5f58 Readme update 2021-09-18 12:53:14 -05:00
Mark Cooper
b2e129eec7 Readme update 2021-09-18 12:52:38 -05:00
advplyr
540f3e4196 Readme update 2021-09-18 12:52:38 -05:00
Mark Cooper
cb79e48685 Readme Update 2021-09-18 12:50:22 -05:00
advplyr
043b353333 Readme Update 2021-09-18 12:50:22 -05:00
Mark Cooper
e735ef7869 Readme update 2021-09-18 12:49:21 -05:00
advplyr
737f8342f4 Readme update 2021-09-18 12:49:21 -05:00
Mark Cooper
8f1152762a Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
advplyr
c884fd6880 Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
Mark Cooper
587adb3773 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
advplyr
07994d38d5 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
Mark Cooper
db01db3a2b Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
advplyr
80e2e35dcd Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
Mark Cooper
0851a1e71e Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
advplyr
bb7e79004d Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
Mark Cooper
0addfc8269 Readme upcoming features update 2021-09-16 08:44:39 -05:00
advplyr
62d0a82dde Readme upcoming features update 2021-09-16 08:44:39 -05:00
Mark Cooper
b2ab5730f5 Add batch read/not read update, Update tooltip positions 2021-09-16 08:37:09 -05:00
advplyr
5f69339a27 Add batch read/not read update, Update tooltip positions 2021-09-16 08:37:09 -05:00
Mark Cooper
7859d7a502 Add version checker 2021-09-15 17:59:38 -05:00
advplyr
bd0e8518be Add version checker 2021-09-15 17:59:38 -05:00
Mark Cooper
174fce9614 Adding download zip file, fix local cover art for m4b download 2021-09-14 20:45:00 -05:00
advplyr
a56b3a8096 Adding download zip file, fix local cover art for m4b download 2021-09-14 20:45:00 -05:00
Mark Cooper
3dfd7ea035 Add audiobook uploader 2021-09-13 20:18:58 -05:00
advplyr
a7c538193c Add audiobook uploader 2021-09-13 20:18:58 -05:00
Mark Cooper
6cb253598b Update user progress reset and delete logic 2021-09-12 18:22:52 -05:00
advplyr
5dd1542af0 Update user progress reset and delete logic 2021-09-12 18:22:52 -05:00
Mark Cooper
11f4caffa8 Add socket event to remove download, fix clearInterval on stream loop 2021-09-12 16:10:12 -05:00
advplyr
9107620b3c Add socket event to remove download, fix clearInterval on stream loop 2021-09-12 16:10:12 -05:00
Mark Cooper
80f90907d4 Update merge for m4b files, add progress event for local audiobooks 2021-09-11 19:59:48 -05:00
advplyr
c30955f909 Update merge for m4b files, add progress event for local audiobooks 2021-09-11 19:59:48 -05:00
Mark Cooper
4e92ea3992 Update scanner v3, add isActive support for users 2021-09-10 19:55:02 -05:00
advplyr
beaa1e14bb Update scanner v3, add isActive support for users 2021-09-10 19:55:02 -05:00
Mark Cooper
ddbf678a8b Merge tracks with codec copy' 2021-09-09 05:10:55 -05:00
advplyr
394d312282 Merge tracks with codec copy' 2021-09-09 05:10:55 -05:00
Mark Cooper
315de87bfc Adding chapters and downloading m4b file 2021-09-08 09:15:54 -05:00
advplyr
299fc95c78 Adding chapters and downloading m4b file 2021-09-08 09:15:54 -05:00
Mark Cooper
26d922d3dc Auto add/update/remove audiobooks, update screenshots 2021-09-06 20:14:04 -05:00
advplyr
07a2a0aefd Auto add/update/remove audiobooks, update screenshots 2021-09-06 20:14:04 -05:00
Mark Cooper
ee452d41ee Adding permissions per user, add volume number sort 2021-09-06 17:42:15 -05:00
advplyr
2e82370408 Adding permissions per user, add volume number sort 2021-09-06 17:42:15 -05:00
Mark Cooper
1d7d2a1dac Fix details tab save 2021-09-06 16:11:37 -05:00
advplyr
c8d857edb9 Fix details tab save 2021-09-06 16:11:37 -05:00
Mark Cooper
41c391e87b Update user audiobook progress model, add mark as read/not read, download individual tracks 2021-09-06 14:13:01 -05:00
advplyr
11d7330c5d Update user audiobook progress model, add mark as read/not read, download individual tracks 2021-09-06 14:13:01 -05:00
Mark Cooper
1f2afe4d92 Editing accounts, change root account username, removed token expiration 2021-09-05 18:20:29 -05:00
advplyr
4f094df0b2 Editing accounts, change root account username, removed token expiration 2021-09-05 18:20:29 -05:00
Mark Cooper
e534d015be Allow any utf-8 char in genre and tags, fix stream manager user undefined 2021-09-05 14:30:33 -05:00
advplyr
838d188504 Allow any utf-8 char in genre and tags, fix stream manager user undefined 2021-09-05 14:30:33 -05:00
Mark Cooper
d2a2f3ff6a New filters using base64 strings, keyword filter 2021-09-05 13:21:02 -05:00
advplyr
6f6a3f71b3 New filters using base64 strings, keyword filter 2021-09-05 13:21:02 -05:00
Mark Cooper
af05e78cdf Add Subtitle and Narrarator fields, add server settings object, scanner to parse out subtitles 2021-09-04 19:58:39 -05:00
advplyr
a66a84bd2d Add Subtitle and Narrarator fields, add server settings object, scanner to parse out subtitles 2021-09-04 19:58:39 -05:00
Mark Cooper
e566c6c9d5 Improve track order detection, allow for excluding audio files from tracklist 2021-09-04 18:02:42 -05:00
advplyr
af0365c81f Improve track order detection, allow for excluding audio files from tracklist 2021-09-04 18:02:42 -05:00
advplyr
45ee42bddd Version 1.0.0, updating readme 2021-09-04 14:35:25 -05:00
advplyr
1aff255170 Adding download tab and download manager, ffmpeg in worker thread 2021-09-04 14:17:26 -05:00
advplyr
27956bd67a Fix server client.user undefined, update logo 2021-09-03 06:40:59 -05:00
advplyr
6a2d7af720 fix variable typo 2021-09-01 19:50:18 -05:00
advplyr
3c854ca32b Emit stream_closed even if stream is not found 2021-09-01 19:39:38 -05:00
advplyr
c5bc865e6c Update ver 2021-09-01 16:01:53 -05:00
advplyr
7a35342577 Logo update, fix book card shadow 2021-09-01 16:01:15 -05:00
advplyr
1b63d90cd7 Remove test fonts 2021-09-01 13:50:37 -05:00
advplyr
7ddb4f0539 Fix multi-select, add new book flag 2021-09-01 13:47:18 -05:00
advplyr
28966e191b Add m4a filetype 2021-08-27 14:35:16 -05:00
advplyr
35808abdf6 Adding and deleting users 2021-08-27 07:01:47 -05:00
advplyr
db0ecfb51b Batch updating and deleting, multi-select 2021-08-26 18:32:05 -05:00
advplyr
64cc7efa6a Fix set card size index on mount 2021-08-26 10:43:46 -05:00
advplyr
b4a62dbf4b Bookshelf cover size setting and widget 2021-08-26 09:47:51 -05:00
advplyr
091aa6ef82 Cover image aspect ratio solution 2021-08-26 08:04:52 -05:00
advplyr
c208e876d2 Fix update tracklist and invalid parts alert, update readme screenshots 2021-08-26 07:09:23 -05:00
advplyr
3141e74513 Sync tracks always 2021-08-25 19:32:17 -05:00
advplyr
1dc01615dd Fix listener for audiobook updates in edit modal, add remove cover button 2021-08-25 19:15:00 -05:00
advplyr
cb40e063da Adding inode to files and audiobooks to support renaming, setting up watcher and removing chokidar 2021-08-25 17:36:54 -05:00
advplyr
0c1a29adbf Parse and update author name on each update 2021-08-25 06:38:32 -05:00
advplyr
6e8270c5ea Emit update when cover is updated 2021-08-24 20:32:13 -05:00
advplyr
51357195e2 Clean and parse author name from directory, sort by author last name, scan for covers 2021-08-24 20:24:40 -05:00
advplyr
759be593b6 Fix ab undefined 2021-08-24 08:04:32 -05:00
advplyr
82241fd2f2 Fix incorrect audiobook file paths before a scan 2021-08-24 07:50:36 -05:00
advplyr
d63e582e56 package lock sync 2021-08-24 07:36:20 -05:00
advplyr
c59cc52667 Scanner update - remove and update audiobooks on scans 2021-08-24 07:15:56 -05:00
advplyr
db2f2d6660 Fix dynamic route requests, add auth middleware 2021-08-23 19:37:40 -05:00
advplyr
7ef977b783 Moving settings to be user specific, adding playbackRate setting, update playbackRate picker to go up to 3x 2021-08-23 18:31:04 -05:00
advplyr
40502aab1e Update readme 2021-08-23 14:20:33 -05:00
advplyr
33bbf1a49b Increment beta verison 2021-08-23 14:14:19 -05:00
advplyr
a93f409dcd Fix scan for audiobook directories in root dir 2021-08-23 14:08:54 -05:00
advplyr
5ecfaa88c2 Reset password and users table on settings page 2021-08-22 10:46:04 -05:00
advplyr
e7898377ed remove comment 2021-08-22 09:26:03 -05:00
advplyr
c3fd9045a8 Series as a dropdown and filter, fix genre list in details modal 2021-08-22 08:52:37 -05:00
advplyr
f70e1beca1 Add global search, add reset all audiobooks 2021-08-21 16:23:35 -05:00
advplyr
fb0a6f4ec2 Removing release-it 2021-08-21 14:02:10 -05:00
advplyr
696766945c Release 0.9.61-beta.0 2021-08-21 13:28:32 -05:00
advplyr
5e9d40b663 update release-update command 2021-08-21 13:27:46 -05:00
advplyr
1c453a3560 release-update command 2021-08-21 13:26:43 -05:00
advplyr
343657fe97 Release 0.9.61-beta 2021-08-21 13:15:45 -05:00
advplyr
e04b5d3152 Disable npm publish release-it 2021-08-21 13:13:33 -05:00
advplyr
30ce8fee76 Updating version to include -beta 2021-08-21 13:05:46 -05:00
advplyr
f30fa2fb0c Use local image as cover if found, adding release-it version control 2021-08-21 13:02:24 -05:00
advplyr
7d4e2e3d97 Update book finder and cover matching - includes LibGen provider 2021-08-21 09:15:44 -05:00
advplyr
744aacbb4b Adding audio playback speed control, updating volume control UI, fix stream play for small streams 2021-08-20 18:29:10 -05:00
advplyr
506f16c431 Fix readme 2021-08-19 19:21:04 -05:00
advplyr
08c3f84c06 Supporting more file structures for series and publish year 2021-08-19 19:14:24 -05:00
advplyr
7df0708f38 Update readme image urls 2021-08-19 17:32:40 -05:00
advplyr
d59aefd8c7 Cleaning up, adding readme and images, genre filter 2021-08-19 17:29:36 -05:00
advplyr
2c5b5dbeae removing .nuxt 2021-08-19 11:31:03 -05:00
advplyr
1c88c0a796 Sorting and filtering 2021-08-18 20:18:44 -05:00
advplyr
7e482352b1 Adding files tables, fixing loading when switching streams 2021-08-18 18:31:19 -05:00
advplyr
6e8fe32bf5 Sorting, fix user object bug, add settings module 2021-08-18 06:50:24 -05:00
advplyr
9a74825bad Add version to config 2021-08-17 17:51:07 -05:00
advplyr
dee7bcb2c2 Update cover url, audiobook player speed 2021-08-17 17:43:29 -05:00
advplyr
8fbdc1523d Update Logo, Add docker template 2021-08-17 17:19:57 -05:00
advplyr
6930e69b55 Init 2021-08-17 17:01:11 -05:00
196 changed files with 17831 additions and 1987 deletions

View File

@@ -6,8 +6,10 @@ npm-debug.log
/config
/audiobooks
/audiobooks2
/media/
/metadata
dev.js
test/
/client/.nuxt/
/client/dist/
/client/dist/
/dist/

4
.gitignore vendored
View File

@@ -4,7 +4,9 @@ node_modules/
/config/
/audiobooks/
/audiobooks2/
/media/
/metadata/
test/
/client/.nuxt/
/client/dist/
/client/dist/
/dist/

View File

@@ -1,19 +1,15 @@
### STAGE 0: FFMPEG ###
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
### STAGE 1: Build client ###
### STAGE 0: Build client ###
FROM node:12-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm run generate
### STAGE 2: Build server ###
### STAGE 1: Build server ###
FROM node:12-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
ENV LOG_LEVEL=INFO
COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / /
COPY index.js index.js
COPY package.json package.json
COPY server server

View File

@@ -0,0 +1,8 @@
Package: audiobookshelf
Version: 1.2.1
Section: base
Priority: optional
Architecture: amd64
Depends:
Maintainer: advplyr
Description: Self-hosted audiobook server for managing and playing audiobooks

View File

@@ -0,0 +1,85 @@
#!/bin/bash
set -e
set -o pipefail
declare -r init_type='auto'
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 () {
: "${1:?'Service name was not defined'}"
declare -r service_name="$1"
if hash systemctl 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' ]]; then
{
systemctl enable "$service_name.service" && \
systemctl start "$service_name.service"
} || echo "$service_name could not be registered or started"
fi
elif hash service 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
service "$service_name" start || echo "$service_name could not be registered or started"
fi
elif hash start 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' ]]; then
start "$service_name" || echo "$service_name could not be registered or started"
fi
elif hash update-rc.d 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'sysv' ]]; then
{
update-rc.d "$service_name" defaults && \
"/etc/init.d/$service_name" start
} || echo "$service_name could not be registered or started"
fi
else
echo 'Your system does not appear to use systemd, Upstart, or System V, so the service could not be started'
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
mkdir -p '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
start_service 'audiobookshelf'

View File

@@ -0,0 +1,99 @@
#!/bin/bash
set -e
set -o pipefail
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331
CONFIG_PATH="/etc/default/audiobookshelf"
install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
TARGET_DIR="/usr/lib/audiobookshelf-ffmpeg/"
if ! cd "$TARGET_DIR"; then
echo "WARNING: can't access working directory ($TARGET_DIR) creating it" >&2
mkdir "$TARGET_DIR"
cd "$TARGET_DIR"
fi
$WGET
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
rm ffmpeg-git-amd64-static.tar.xz
chown -R 'audiobookshelf:audiobookshelf' "$TARGET_DIR"
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() {
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
PORT=$PORT"
echo "$config_text"
echo "$config_text" > /etc/default/audiobookshelf;
echo "Config created"
fi
}
setup_config
install_ffmpeg

26
build/debian/DEBIAN/prerm Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
set -o pipefail
declare -r init_type='auto'
declare -r service_name='audiobookshelf'
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
if hash systemctl 2> /dev/null; then
systemctl disable "$service_name.service" && \
systemctl stop "$service_name.service" || \
echo "$service_name wasn't even running!"
elif hash service 2> /dev/null; then
service "$service_name" stop || echo "$service_name wasn't even running!"
elif hash stop 2> /dev/null; then
stop "$service_name" || echo "$service_name wasn't even running!"
elif hash update-rc.d 2> /dev/null; then
{
update-rc.d "$service_name" remove && \
"/etc/init.d/$service_name" stop
} || "$service_name wasn't even running!"
else
echo "Your system does not appear to use upstart, systemd or sysv, so $service_name could not be stopped"
echo 'Unless these systems were removed since install, no processes have been left running'
fi
fi

View File

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Self-hosted audiobook server for managing and playing audiobooks
Requires=network.target
[Service]
Type=simple
EnvironmentFile=/etc/default/audiobookshelf
WorkingDirectory=/usr/share/audiobookshelf
ExecStart=/usr/share/audiobookshelf/audiobookshelf
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
PermissionsStartOnly=true
[Install]
WantedBy=multi-user.target

View File

View File

57
build/linuxpackager Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
set -e
set -o pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$SCRIPT_DIR/.."
# Get package version without double quotes
VERSION="$( eval echo $( jq '.version' package.json) )"
DESCRIPTION="$( eval echo $( jq '.description' package.json) )"
OUTPUT_FILE="audiobookshelf_${VERSION}_amd64.deb"
echo ">>> Building Client"
echo "--------------------"
cd client
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
npm run generate
cd ..
echo ">>> Building Server"
echo "--------------------"
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
echo ">>> Packaging"
echo "--------------------"
# Create debian control file
mkdir -p dist
cp -R build/debian dist/debian
chmod -R 775 dist/debian
controlfile="Package: audiobookshelf
Version: $VERSION
Section: base
Priority: optional
Architecture: amd64
Depends:
Maintainer: advplyr
Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian
mv dist/debian.deb "dist/$OUTPUT_FILE"
chmod +x "dist/$OUTPUT_FILE"
echo "Finished! Filename: $OUTPUT_FILE"

View File

@@ -1,4 +1,6 @@
@import url('./transitions.css');
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
.page {
width: 100%;
@@ -9,20 +11,41 @@
height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px);
}
#bookshelf {
height: calc(100% - 40px);
}
/* width */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar:horizontal {
height: 8px;
}
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */
::-webkit-scrollbar-track {
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 */
::-webkit-scrollbar-thumb {
background: #855620;
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 */
::-webkit-scrollbar-thumb:hover {
background: #704922;
@@ -33,6 +56,19 @@
opacity: 0;
}
/* Chrome, Safari, Edge, Opera */
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
.tracksTable {
border-collapse: collapse;
width: 100%;
@@ -48,10 +84,10 @@
background-color: #474747;
}
.tracksTable td {
padding: 4px;
padding: 4px 8px;
}
.tracksTable th {
padding: 4px;
padding: 4px 8px;
font-size: 0.75rem;
}
@@ -83,6 +119,14 @@
box-shadow: 2px 8px 6px #111111aa;
}
.box-shadow-sm-up {
box-shadow: 0px -5px 8px #11111122;
}
.box-shadow-md-up {
box-shadow: 0px -8px 8px #11111144;
}
.box-shadow-lg-up {
box-shadow: 0px -12px 8px #111111ee;
}
@@ -93,4 +137,12 @@
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
}
.box-shadow-book3d {
box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.box-shadow-side {
box-shadow: 5px 0px 5px #11111166;
}

View File

@@ -0,0 +1,38 @@
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25);
}
.list-group {
min-height: 30px;
}
#librariesTable .item {
cursor: n-resize;
}
.list-group-item:not(.exclude) {
cursor: n-resize;
}
.list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}

View File

@@ -0,0 +1,506 @@
/*
Calibres stylesheet
*/
export default `
@charset "UTF-8";
/*
Calibre styles
*/
.arabic {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.attribution {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
margin: 0.3em 0
}
.big {
font-size: 1.375em;
line-height: 1.2
}
.big1 {
font-size: 1em
}
.block {
display: block;
text-align: justify;
margin: 1em 1em 2em
}
.block1 {
display: block;
text-align: justify;
margin: 1em 4em
}
.block2 {
display: block;
text-align: justify;
margin: 1em 1em 1em 2em
}
.bullet {
display: block;
list-style-type: disc;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: disc
}
.calibre {
background-color: #000007;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre1 {
display: block
}
.calibre2 {
height: auto;
width: auto
}
.calibre3:not(strong) {
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
margin: 0 5pt
}
.calibre4 {
font-weight: bold
}
.calibre5 {
font-style: italic
}
.calibre6 {
background-color: #FFF;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre7 {
display: list-item
}
.calibre8 {
font-size: 1em;
line-height: 1.2;
vertical-align: super
}
.calibre9 {
border-collapse: separate;
border-spacing: 2px;
display: table;
margin-bottom: 0;
margin-top: 0;
text-indent: 0
}
.calibre10 {
display: table-row;
vertical-align: middle
}
.calibre11 {
display: table-cell;
text-align: right;
vertical-align: inherit;
padding: 1px
}
.calibre12 {
display: table-cell;
text-align: left;
vertical-align: inherit;
padding: 1px
}
.calibre13 {
height: 1em;
width: auto
}
.calibre14 {
font-size: 0.88889em;
line-height: 1.2;
vertical-align: super
}
.calibre15 {
font-size: 1em;
line-height: 1.2;
vertical-align: sub
}
.calibre16 {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em
}
.calibre17 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 0.83em 0
}
.center {
display: block;
text-align: center;
margin: 1em 0
}
.center1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: -2em 0 3em
}
.center2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 1em
}
.center3 {
display: block;
text-align: center;
margin: -1em 0 1em
}
.center4 {
display: block;
text-align: center;
text-indent: 3%;
margin: 1em 0
}
.chapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 1em
}
.chapter1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 0.5em;
margin-right: 0.5em;
margin-top: 2em
}
.chapter2 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 3em
}
.copyright {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em;
text-align: center
}
.dedication {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em
}
.dropcaps {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: -0.05em;
padding-top: 1px
}
.dropcaps1 {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: 0.15em;
padding-top: 1px
}
.extract {
display: block;
text-align: justify;
margin: 2em 0 0.3em
}
.extract1 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 2em 0 0.3em
}
.extract2 {
display: block;
text-align: justify;
margin: 1em 0 0.3em
}
.footnote {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 1em;
line-height: 1.2;
margin-top: 2 em
}
.footnote1 {
display: block;
text-align: justify;
margin: 0.3em 0 0.3em 2
}
.footnote2 {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 2 em
}
.hanging {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1.5em
}
.hanging2 {
display: block;
font-size: 1em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging3 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 1em;
margin: 0.1em 0 0.3em 1em
}
.hanging4 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 0.1em;
margin: 0.1em 0 0.3em 1em
}
a.hlink {
text-decoration: none
}
.indent {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0
}
.line {
border-top: currentColor solid 1px;
border-bottom: currentColor solid 1px
}
.loweralpha {
display: block;
list-style-type: lower-alpha;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none {
display: block;
list-style-type: none;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none1 {
display: block;
list-style-type: none;
margin-bottom: 0;
margin-right: 0;
margin-top: 0;
text-align: justify
}
.nonindent {
display: block;
text-align: justify;
margin: 0.3em 0
}
.nonindent1 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 0.1em
}
.nonindent2 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em -0.5em
}
.nonindent3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0
}
.part {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 4em 0 1em
}
.preface {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 2em;
margin-right: 2em;
text-align: justify
}
.pubhlink {
color: green;
text-decoration: none
}
.right {
display: block;
text-align: right;
margin: 0.3em 0
}
.section {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 0.5em
}
.section1 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em
}
.section2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em 1em
}
.small {
font-size: 0.66667em
}
.small1 {
font-size: 0.75em
}
.subchapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 1em 0
}
.textbox {
background-color: #E4E4E4;
display: block;
line-height: 1.5em;
margin-bottom: 2em;
margin-top: 2em;
text-align: justify;
border-top: currentColor double 2px;
border-bottom: currentColor double 2px
}
.textbox1 {
display: block;
text-align: justify;
margin: 0.3em 0.5em 0.3em 0.8em
}
.textbox2 {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0.5em
}
.textbox3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0.5em 0.3em 0.8em
}
.titlepage {
display: block;
margin-left: -0.4em;
margin-top: 1.2em
}
.toc {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: center
}
.toc1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 0.67em 0 3em
}
.underline {
text-decoration: underline
}
`

View File

@@ -0,0 +1,248 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
export const isTitle = (
line,
isContainDI = false,
isContainChapter = false,
isContainCHAPTER = false
) => {
return (
line.length < 30 &&
line.indexOf("[") === -1 &&
line.indexOf("(") === -1 &&
(line.startsWith("CHAPTER") ||
line.startsWith("Chapter") ||
line.startsWith("序章") ||
line.startsWith("前言") ||
line.startsWith("声明") ||
line.startsWith("聲明") ||
line.startsWith("写在前面的话") ||
line.startsWith("后记") ||
line.startsWith("楔子") ||
line.startsWith("后序") ||
line.startsWith("寫在前面的話") ||
line.startsWith("後記") ||
line.startsWith("後序") ||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line
) ||
(line.startsWith("第") && startWithDI(line)) ||
(line.startsWith("卷") && startWithJUAN(line)) ||
startWithRomanNum(line) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("第") > -1 &&
(line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === "、" ||
line[line.indexOf("第") - 1] === "" ||
line[line.indexOf("第") - 1] === ":") &&
startWithDI(line.substr(line.indexOf("第")))) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("、") &&
startWithNumAndPause(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("") &&
startWithNumAndColon(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(":") &&
startWithNumAndColon(line)))
);
};
const startWithDI = (line) => {
let keywords = [
"章",
"节",
"回",
"節",
"卷",
"部",
"輯",
"辑",
"話",
"集",
"话",
"篇",
];
let flag = false;
for (let i = 0; i < keywords.length; i++) {
if (
(line.indexOf(keywords[i]) > -1 &&
(line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === "、" ||
line[line.indexOf(keywords[i]) + 1] === "" ||
line[line.indexOf(keywords[i]) + 1] === ":")) ||
!line[line.indexOf(keywords[i]) + 1]
) {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(keywords[i])).trim()
) ||
/^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())
) {
flag = true;
}
if (flag) break;
}
}
return flag;
};
const startWithJUAN = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1)
) ||
/^\d+$/.test(line.substring(1))
)
return true;
return false;
};
const startWithRomanNum = (line) => {
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf("."))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.trim()
)
)
return true;
return false;
};
const startWithNumAndSpace = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
return false;
};
const startWithNumAndColon = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(":"))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(""))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("")))) return true;
return false;
};
const startWithNumAndPause = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf("、"))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true;
return false;
};
class HtmlParser {
bookDoc;
contentList;
contentTitleList;
constructor(bookDoc) {
this.bookDoc = bookDoc;
this.contentList = [];
this.contentTitleList = [];
this.getContent(bookDoc);
}
getContent(bookDoc) {
this.contentList = Array.from(
bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font")
).filter((item, index) => {
return isTitle(item.innerText.trim());
});
for (let i = 0; i < this.contentList.length; i++) {
let random = Math.floor(Math.random() * 900000) + 100000;
this.contentTitleList.push({
label: this.contentList[i].innerText,
id: "title" + random,
href: "#title" + random,
subitems: [],
});
}
for (let i = 0; i < this.contentList.length; i++) {
this.contentList[i].id = this.contentTitleList[i].id;
}
}
getAnchoredDoc() {
return this.bookDoc;
}
getContentList() {
return this.contentTitleList.filter((item, index) => {
if (index > 0) {
return item.label !== this.contentTitleList[index - 1].label;
} else {
return true;
}
});
}
}
export default HtmlParser;

View File

@@ -0,0 +1,450 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
function ab2str(buf) {
if (buf instanceof ArrayBuffer) {
buf = new Uint8Array(buf);
}
return new TextDecoder("utf-8").decode(buf);
}
var domParser = new DOMParser();
class Buffer {
capacity;
fragment_list;
imageArray;
cur_fragment;
constructor(capacity) {
this.capacity = capacity;
this.fragment_list = [];
this.imageArray = [];
this.cur_fragment = new Fragment(capacity);
this.fragment_list.push(this.cur_fragment);
}
write(byte) {
var result = this.cur_fragment.write(byte);
if (!result) {
this.cur_fragment = new Fragment(this.capacity);
this.fragment_list.push(this.cur_fragment);
this.cur_fragment.write(byte);
}
}
get(idx) {
var fi = 0;
while (fi < this.fragment_list.length) {
var frag = this.fragment_list[fi];
if (idx < frag.size) {
return frag.get(idx);
}
idx -= frag.size;
fi += 1;
}
return null;
}
size() {
var s = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
s += this.fragment_list[i].size;
}
return s;
}
shrink() {
var total_buffer = new Uint8Array(this.size());
var offset = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
var frag = this.fragment_list[i];
if (frag.full()) {
total_buffer.set(frag.buffer, offset);
} else {
total_buffer.set(frag.buffer.slice(0, frag.size), offset);
}
offset += frag.size;
}
return total_buffer;
}
}
var copagesne_uint8array = function (buffers) {
var total_size = 0;
for (let i = 0; i < buffers.length; i++) {
var buffer = buffers[i];
total_size += buffer.length;
}
var total_buffer = new Uint8Array(total_size);
var offset = 0;
for (let i = 0; i < buffers.length; i++) {
buffer = buffers[i];
total_buffer.set(buffer, offset);
offset += buffer.length;
}
return total_buffer;
};
class Fragment {
buffer;
capacity;
size;
constructor(capacity) {
this.buffer = new Uint8Array(capacity);
this.capacity = capacity;
this.size = 0;
}
write(byte) {
if (this.size >= this.capacity) {
return false;
}
this.buffer[this.size] = byte;
this.size += 1;
return true;
}
full() {
return this.size === this.capacity;
}
get(idx) {
return this.buffer[idx];
}
}
var uncompression_lz77 = function (data) {
var length = data.length;
var offset = 0; // Current offset into data
var buffer = new Buffer(data.length);
while (offset < length) {
var char = data[offset];
offset += 1;
if (char === 0) {
buffer.write(char);
} else if (char <= 8) {
for (var i = offset; i < offset + char; i++) {
buffer.write(data[i]);
}
offset += char;
} else if (char <= 0x7f) {
buffer.write(char);
} else if (char <= 0xbf) {
var next = data[offset];
offset += 1;
var distance = (((char << 8) | next) >> 3) & 0x7ff;
var lz_length = (next & 0x7) + 3;
var buffer_size = buffer.size();
for (let i = 0; i < lz_length; i++) {
buffer.write(buffer.get(buffer_size - distance));
buffer_size += 1;
}
} else {
buffer.write(32);
buffer.write(char ^ 0x80);
}
}
return buffer;
};
class MobiFile {
view;
buffer;
offset;
header;
palm_header;
mobi_header;
reclist;
constructor(data) {
this.view = new DataView(data);
this.buffer = this.view.buffer;
this.offset = 0;
this.header = null;
}
parse() { }
getUint8() {
var v = this.view.getUint8(this.offset);
this.offset += 1;
return v;
}
getUint16() {
var v = this.view.getUint16(this.offset);
this.offset += 2;
return v;
}
getUint32() {
var v = this.view.getUint32(this.offset);
this.offset += 4;
return v;
}
getStr(size) {
var v = ab2str(this.buffer.slice(this.offset, this.offset + size));
this.offset += size;
return v;
}
skip(size) {
this.offset += size;
}
setoffset(_of) {
this.offset = _of;
}
get_record_extrasize(data, flags) {
var pos = data.length - 1;
var extra = 0;
for (var i = 15; i > 0; i--) {
if (flags & (1 << i)) {
var res = this.buffer_get_varlen(data, pos);
var size = res[0];
var l = res[1];
pos = res[2];
pos -= size - l;
extra += size;
}
}
if (flags & 1) {
var a = data[pos];
extra += (a & 0x3) + 1;
}
return extra;
}
// data should be uint8array
buffer_get_varlen(data, pos) {
var l = 0;
var size = 0;
var byte_count = 0;
var mask = 0x7f;
var stop_flag = 0x80;
var shift = 0;
for (var i = 0; ; i++) {
var byte = data[pos];
size |= (byte & mask) << shift;
shift += 7;
l += 1;
byte_count += 1;
pos -= 1;
var to_stop = byte & stop_flag;
if (byte_count >= 4 || to_stop > 0) {
break;
}
}
return [size, l, pos];
}
// 读出文本内容
read_text() {
var text_end = this.palm_header.record_count;
var buffers = [];
for (var i = 1; i <= text_end; i++) {
buffers.push(this.read_text_record(i));
}
var all = copagesne_uint8array(buffers);
return ab2str(all);
}
read_text_record(i) {
var flags = this.mobi_header.extra_flags;
var begin = this.reclist[i].offset;
var end = this.reclist[i + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
var ex = this.get_record_extrasize(data, flags);
data = new Uint8Array(this.buffer.slice(begin, end - ex));
if (this.palm_header.compression === 2) {
var buffer = uncompression_lz77(data);
return buffer.shrink();
} else {
return data;
}
}
// 从buffer中读出image
read_image(idx) {
var first_image_idx = this.mobi_header.first_image_idx;
var begin = this.reclist[first_image_idx + idx].offset;
var end = this.reclist[first_image_idx + idx + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
return new Blob([data.buffer]);
}
load() {
this.header = this.load_pdbheader();
this.reclist = this.load_reclist();
this.load_record0();
}
load_pdbheader() {
var header = {};
header.name = this.getStr(32);
header.attr = this.getUint16();
header.version = this.getUint16();
header.ctime = this.getUint32();
header.mtime = this.getUint32();
header.btime = this.getUint32();
header.mod_num = this.getUint32();
header.appinfo_offset = this.getUint32();
header.sortinfo_offset = this.getUint32();
header.type = this.getStr(4);
header.creator = this.getStr(4);
header.uid = this.getUint32();
header.next_rec = this.getUint32();
header.record_num = this.getUint16();
return header;
}
load_reclist() {
var reclist = [];
for (var i = 0; i < this.header.record_num; i++) {
var record = {};
record.offset = this.getUint32();
// TODO(zz) change
record.attr = this.getUint32();
reclist.push(record);
}
return reclist;
}
load_record0() {
this.palm_header = this.load_record0_header();
this.mobi_header = this.load_mobi_header();
}
load_record0_header() {
var p_header = {};
var first_record = this.reclist[0];
this.setoffset(first_record.offset);
p_header.compression = this.getUint16();
this.skip(2);
p_header.text_length = this.getUint32();
p_header.record_count = this.getUint16();
p_header.record_size = this.getUint16();
p_header.encryption_type = this.getUint16();
this.skip(2);
return p_header;
}
load_mobi_header() {
var mobi_header = {};
var start_offset = this.offset;
mobi_header.identifier = this.getUint32();
mobi_header.header_length = this.getUint32();
mobi_header.mobi_type = this.getUint32();
mobi_header.text_encoding = this.getUint32();
mobi_header.uid = this.getUint32();
mobi_header.generator_version = this.getUint32();
this.skip(40);
mobi_header.first_nonbook_index = this.getUint32();
mobi_header.full_name_offset = this.getUint32();
mobi_header.full_name_length = this.getUint32();
mobi_header.language = this.getUint32();
mobi_header.input_language = this.getUint32();
mobi_header.output_language = this.getUint32();
mobi_header.min_version = this.getUint32();
mobi_header.first_image_idx = this.getUint32();
mobi_header.huff_rec_index = this.getUint32();
mobi_header.huff_rec_count = this.getUint32();
mobi_header.datp_rec_index = this.getUint32();
mobi_header.datp_rec_count = this.getUint32();
mobi_header.exth_flags = this.getUint32();
this.skip(36);
mobi_header.drm_offset = this.getUint32();
mobi_header.drm_count = this.getUint32();
mobi_header.drm_size = this.getUint32();
mobi_header.drm_flags = this.getUint32();
this.skip(8);
// TODO (zz) fdst_index
this.skip(4);
this.skip(46);
mobi_header.extra_flags = this.getUint16();
this.setoffset(start_offset + mobi_header.header_length);
return mobi_header;
}
load_exth_header() {
// TODO
return {};
}
extractContent(s) {
var span = document.createElement("span");
span.innerHTML = s;
return span.textContent || span.innerText;
}
render(isElectron = false) {
return new Promise((resolve, reject) => {
this.load();
var content = this.read_text();
var bookDoc = domParser.parseFromString(content, "text/html")
.documentElement;
let lines = Array.from(
bookDoc.querySelectorAll("p,b,font,h3,h2,h1")
);
let parseContent = [];
for (let i = 0, len = lines.length; i < len - 1; i++) {
lines[i].innerText &&
lines[i].innerText !== parseContent[parseContent.length - 1] &&
parseContent.push(lines[i].innerText);
let imgDoms = lines[i].getElementsByTagName("img");
if (imgDoms.length > 0) {
for (let i = 0; i < imgDoms.length; i++) {
parseContent.push("#image");
}
}
}
const handleImage = async () => {
var imgDoms = bookDoc.getElementsByTagName("img");
parseContent.push("~image");
for (let i = 0; i < imgDoms.length; i++) {
const src = await this.render_image(imgDoms, i);
parseContent.push(
src + " " + imgDoms[i].width + " " + imgDoms[i].height
);
}
if (imgDoms.length > 200 || !isElectron) {
resolve(bookDoc);
} else {
resolve(parseContent.join("\n \n"));
}
};
handleImage();
});
}
render_image = (imgDoms, i) => {
return new Promise((resolve, reject) => {
var imgDom = imgDoms[i];
var idx = +imgDom.getAttribute("recindex");
var blob = this.read_image(idx - 1);
var imgReader = new FileReader();
imgReader.onload = (e) => {
imgDom.src = e.target?.result;
resolve(e.target?.result);
};
imgReader.onerror = function (err) {
reject(err);
};
imgReader.readAsDataURL(blob);
});
};
}
export default MobiFile;

44
client/assets/fonts.css Normal file
View File

@@ -0,0 +1,44 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/material-icons.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-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) {
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
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;
}

View File

@@ -1,19 +1,29 @@
<template>
<div class="w-full">
<div class="w-full relative mb-4">
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
</div>
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div>
<div class="absolute right-24 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<div class="w-full -mt-4">
<div class="w-full relative mb-2">
<div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<div v-if="chapters.length" class="absolute right-20 top-0 bottom-0 h-full flex items-end">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
</div>
<div class="flex pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
@@ -47,10 +57,17 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p>
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
@@ -58,6 +75,8 @@
</div>
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
@@ -66,13 +85,21 @@ import Hls from 'hls.js'
export default {
props: {
loading: Boolean
loading: Boolean,
chapters: {
type: Array,
default: () => []
},
bookmarks: {
type: Array,
default: () => []
}
},
data() {
return {
hlsInstance: null,
staleHlsInstance: null,
volume: 0.5,
volume: 1,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
@@ -84,7 +111,10 @@ export default {
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16 // Track is 16px from edge
}
},
computed: {
@@ -93,9 +123,44 @@ export default {
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() {
if (!this.audioEl) return 0
return this.totalDuration - this.currentTime
},
timeRemainingPretty() {
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.totalDuration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) {
if (this.loading) {
return
@@ -110,7 +175,7 @@ export default {
}
this.seekedTime = time
this.seekLoading = true
console.warn('SEEK TO', this.$secondsToTimestamp(time))
this.audioEl.currentTime = time
if (this.$refs.playedTrack) {
@@ -152,10 +217,29 @@ export default {
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
@@ -166,12 +250,16 @@ export default {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
restart() {
this.seek(0)
this.$nextTick(this.sendStreamUpdate)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
@@ -269,7 +357,6 @@ export default {
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
@@ -287,7 +374,7 @@ export default {
return
}
var lastbuff = this.getLastBufferedTime()
this.sendStreamUpdate()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
@@ -295,7 +382,6 @@ export default {
this.bufferTrackWidth = bufferlen
},
timeupdate() {
// console.log('Time update', this.audioEl.currentTime)
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
@@ -315,6 +401,15 @@ export default {
this.updateTimestamp()
// Send update to server when currentTime > 0
// this prevents errors when seeking to position not yet transcoded
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
if (this.audioEl.currentTime) {
this.sendStreamUpdate()
}
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
@@ -339,6 +434,7 @@ export default {
},
audioLoadedData() {
this.totalDuration = this.audioEl.duration
this.$emit('loaded', this.totalDuration)
},
set(url, currentTime, playOnLoad = false) {
if (this.hlsInstance) {
@@ -361,7 +457,7 @@ export default {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio
audio.volume = this.volume
@@ -380,16 +476,23 @@ export default {
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details)
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance')
console.log('[HLS] Destroying HLS Instance')
})
})
},
showChapters() {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
@@ -410,7 +513,6 @@ export default {
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
console.log('Terminated HLS Instance', this.staleHlsInstance)
}
},
async resetStream(startTime) {
@@ -424,6 +526,9 @@ export default {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
@@ -434,14 +539,66 @@ export default {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<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-6 py-1 z-30">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
<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">
@@ -8,23 +8,46 @@
</a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<ui-libraries-dropdown />
<controls-global-search />
<div class="flex-grow" />
<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">
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-4">logo_dev</span>
<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">
<span class="material-icons">upload</span>
</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 ml-4">
<span class="material-icons">settings</span>
</nuxt-link>
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
<nuxt-link to="/account" class="relative w-32 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center">
<span class="block truncate">{{ username }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
</span>
</nuxt-link>
</div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
>
<div class="flex-grow" />
<ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn>
<ui-btn color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn>
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</div>
</div>
@@ -35,23 +58,21 @@
export default {
data() {
return {
menuItems: [
{
value: 'account',
text: 'Account',
to: '/account'
},
{
value: 'logout',
text: 'Logout'
}
],
processingBatchDelete: false
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
libraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
},
isHome() {
return this.$route.name === 'library-library'
},
showBack() {
return this.$route.name !== 'index'
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
},
user() {
return this.$store.state.user.user
@@ -71,31 +92,44 @@ export default {
isAllSelected() {
return this.audiobooksShowing.length === this.selectedAudiobooks.length
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
},
audiobooksShowing() {
return this.$store.getters['audiobooks/getFiltered']()
// return this.$store.getters['audiobooks/getFiltered']()
return this.$store.getters['audiobooks/getEntitiesShowing']()
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead
})
},
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
back() {
if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`)
} else {
this.$router.push('/')
}
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
menuAction(action) {
if (action === 'logout') {
this.logout()
}
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
@@ -109,8 +143,32 @@ export default {
this.$store.commit('setSelectedAudiobooks', audiobookIds)
}
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
return {
audiobookId: ab,
isRead: newIsRead
}
})
this.$axios
.patch(`/api/user/audiobooks`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
})
.catch((error) => {
this.$toast.error('Batch update failed')
console.error('Failed to batch update read/not read', error)
this.$store.commit('setProcessingBatch', false)
})
},
batchDeleteClick() {
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios

View File

@@ -0,0 +1,127 @@
<template>
<div class="outer-container">
<!-- absolute positioned container -->
<div class="inner-container">
<div class="relative h-10">
<div class="table-header" id="headerdiv">
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th class="header-cell min-w-12 max-w-12"></th>
<th class="header-cell min-w-6 max-w-6"></th>
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
<th class="header-cell min-w-24 max-w-24 px-2"></th>
</tr>
</thead>
</table>
</div>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
</div>
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
<tbody>
<template v-for="book in books">
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
books: {
type: Array,
default: () => []
}
},
data() {
return {
isScrollable: false
}
},
computed: {},
methods: {
checkIsScrolled() {
if (!this.$refs.tableBody) return
this.isScrollable = this.$refs.tableBody.scrollTop > 0
},
tableScrolled() {
this.checkIsScrolled()
},
editBook(book) {
var bookIds = this.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', book)
}
},
mounted() {
this.checkIsScrolled()
},
beforeDestroy() {}
}
</script>
<style>
.outer-container {
position: absolute;
top: 0;
left: 0;
overflow: visible;
height: calc(100% - 50px);
width: calc(100% - 10px);
margin: 10px;
}
.inner-container {
width: 100%;
height: 100%;
position: relative;
}
.table-header {
float: left;
overflow: hidden;
width: 100%;
}
.header-shadow {
box-shadow: 3px 8px 3px #11111155;
}
.table-body {
float: left;
height: 100%;
width: inherit;
overflow-y: scroll;
padding-right: 0px;
}
.header-cell {
background-color: #22222288;
padding: 0px 4px;
text-align: left;
height: 40px;
font-size: 0.9rem;
font-weight: semi-bold;
}
.body-cell {
text-align: left;
font-size: 0.9rem;
}
.book-row {
background-color: #22222288;
}
.book-row:nth-child(odd) {
background-color: #333;
}
.book-row.selected {
background-color: rgba(0, 255, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<tr class="book-row" :class="selected ? 'selected' : ''">
<td class="body-cell min-w-12 max-w-12">
<div class="flex justify-center">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
</div>
</td>
<td class="body-cell min-w-6 max-w-6">
<cards-hover-book-cover :audiobook="book" />
</td>
<td class="body-cell min-w-64 max-w-64 px-2">
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
<p class="truncate">
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
</p>
</nuxt-link>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.authorFL }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ seriesText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<p class="truncate">{{ book.book.publishYear }}</p>
</td>
<td class="body-cell min-w-80 max-w-80 px-2">
<p class="truncate">{{ book.book.description }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ book.book.narrator }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ genresText }}</p>
</td>
<td class="body-cell min-w-48 max-w-48 px-2">
<p class="truncate">{{ tagsText }}</p>
</td>
<td class="body-cell min-w-24 max-w-24 px-2">
<div class="flex">
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
</div>
</td>
</tr>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
},
userAudiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
audiobookId() {
return this.book.id
},
isSelectionMode() {
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
selected: {
get() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
set(val) {
if (this.processingBatch) return
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
}
},
processingBatch() {
return this.$store.state.processingBatch
},
bookObj() {
return this.book.book || {}
},
series() {
return this.bookObj.series || null
},
volumeNumber() {
return this.bookObj.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
genresText() {
if (!this.bookObj.genres) return ''
return this.bookObj.genres.join(', ')
},
tagsText() {
return (this.book.tags || []).join(', ')
},
isMissing() {
return this.book.isMissing
},
isIncomplete() {
return this.book.isIncomplete
},
numEbooks() {
return this.book.numEbooks
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return this.showExperimentalFeatures && this.numEbooks
},
showPlayButton() {
return !this.isMissing && !this.isIncomplete && this.numTracks && !this.isStreaming
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
openEbook() {
this.$store.commit('showEReader', this.book)
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
},
toggleRead() {
var updatePayload = {
isRead: !this.userIsRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
},
editClick() {
this.$emit('edit', this.book)
}
},
mounted() {}
}
</script>

View File

@@ -1,58 +1,128 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
<div class="bookshelf overflow-hidden relative block max-h-full">
<div ref="wrapper" class="h-full w-full relative" :class="isGridMode ? 'overflow-y-scroll' : 'overflow-hidden'">
<!-- Cover size widget -->
<div v-show="!isSelectionMode && isGridMode" class="fixed bottom-2 right-4 z-30">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
</div>
</div>
</div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
</div>
<div class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="audiobook in shelf">
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in categorizedShelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
</div>
</div>
<div v-else class="w-full">
<template v-if="viewMode === 'grid'">
<div class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</template>
<template v-else>
<app-book-list :books="entities" />
</template>
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
page: String,
selectedSeries: String,
searchResults: {
type: Object,
default: () => {}
},
searchQuery: String,
viewMode: String
},
data() {
return {
width: 0,
booksPerRow: 0,
groupedBooks: [],
currFilterOrderKey: null,
shelves: [],
currSearchParams: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40
rowPaddingX: 40,
keywordFilterTimeout: null,
scannerParseSubtitle: false,
wrapperClientWidth: 0
}
},
watch: {
keywordFilter() {
this.checkKeywordFilter()
},
selectedSeries() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
this.setBookshelfEntities()
})
},
searchResults() {
this.$nextTick(() => {
// this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities()
})
},
'$route.query.filter'() {
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
} else if (!this.$route.query.filter && this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}
}
},
computed: {
isGridMode() {
return this.viewMode === 'grid'
},
keywordFilter() {
return this.$store.state.audiobooks.keywordFilter
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
filterOrderKey() {
return this.$store.getters['user/getFilterOrderKey']
sizeMultiplier() {
return this.bookCoverWidth / 120
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
var coverWidth = this.availableSizes[this.selectedSizeIndex]
return coverWidth
},
sizeMultiplier() {
return this.bookCoverWidth / 120
@@ -61,13 +131,107 @@ export default {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
var _width = this.bookCoverWidth + this.paddingX * 2
return this.showGroups ? _width * 1.6 : _width
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
orderDesc() {
return this.$store.getters['user/getUserSetting']('orderDesc')
},
showGroups() {
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
},
categorizedShelves() {
if (this.page !== 'search') return []
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
const shelves = []
if (audiobookSearchResults.length) {
shelves.push({
label: 'Books',
books: audiobookSearchResults.map((absr) => absr.audiobook)
})
}
if (this.searchResults.series && this.searchResults.series.length) {
var seriesGroups = this.searchResults.series.map((seriesResult) => {
return {
type: 'series',
name: seriesResult.series || '',
books: seriesResult.audiobooks || []
}
})
shelves.push({
label: 'Series',
series: seriesGroups
})
}
if (this.searchResults.tags && this.searchResults.tags.length) {
var tagGroups = this.searchResults.tags.map((tagResult) => {
return {
type: 'tags',
name: tagResult.tag || '',
books: tagResult.audiobooks || []
}
})
shelves.push({
label: 'Tags',
series: tagGroups
})
}
return shelves
},
entities() {
if (this.page === '') {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
} else if (this.page === 'search') {
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.map((absr) => absr.audiobook)
} else {
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
var group = seriesGroups.find((group) => group.name === this.selectedSeries)
return group.books
}
return seriesGroups
}
}
},
methods: {
editBook(audiobook) {
var bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
clickGroup(group) {
this.$emit('update:selectedSeries', group.name)
},
clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') {
this.$store.dispatch('user/updateUserSettings', {
filterBy: 'all'
})
} else {
this.setBookshelfEntities()
}
},
checkKeywordFilter() {
clearTimeout(this.keywordFilterTimeout)
this.keywordFilterTimeout = setTimeout(() => {
this.setBookshelfEntities()
}, 500)
},
increaseSize() {
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
this.resize()
@@ -78,60 +242,90 @@ export default {
this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
setGroupedBooks() {
setBookshelfEntities() {
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth)
this.currSearchParams = this.buildSearchParams()
var entities = this.entities
var groups = []
var currentRow = 0
var currentGroup = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
this.currFilterOrderKey = this.filterOrderKey
for (let i = 0; i < audiobooksSorted.length; i++) {
var row = Math.floor(i / this.booksPerRow)
for (let i = 0; i < entities.length; i++) {
var row = Math.floor(i / booksPerRow)
if (row > currentRow) {
groups.push([...currentGroup])
currentRow = row
currentGroup = []
}
currentGroup.push(audiobooksSorted[i])
currentGroup.push(entities[i])
}
if (currentGroup.length) {
groups.push([...currentGroup])
}
this.groupedBooks = groups
this.shelves = groups
},
calculateBookshelf() {
this.width = this.$refs.wrapper.clientWidth
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
var booksPerRow = Math.floor(this.width / this.bookWidth)
this.booksPerRow = booksPerRow
},
getAudiobookCard(id) {
if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) {
return this.$refs[`audiobookCard-${id}`][0]
}
return null
},
init() {
async init() {
this.checkUpdateSearchParams()
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
this.calculateBookshelf()
var isLoading = await this.$store.dispatch('audiobooks/load')
if (!isLoading) {
this.setBookshelfEntities()
}
},
resize() {
this.$nextTick(() => {
this.calculateBookshelf()
this.setGroupedBooks()
})
this.$nextTick(this.setBookshelfEntities)
},
audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks()
this.setBookshelfEntities()
},
buildSearchParams() {
if (this.page === 'search' || this.page === 'series') {
return ''
}
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('order', this.orderBy)
searchParams.set('orderdesc', this.orderDesc ? 1 : 0)
}
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (newSearchParams === '') {
return false
}
if (newSearchParams !== this.currSearchParams || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
return true
}
return false
},
settingsUpdated(settings) {
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks()
}
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) this.setBookshelfEntities()
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) {
@@ -141,28 +335,35 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
updated() {
if (this.$refs.wrapper) {
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
this.$nextTick(this.setBookshelfEntities)
}
}
},
mounted() {
window.addEventListener('resize', this.resize)
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.dispatch('audiobooks/load')
this.init()
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
}
}
</script>
<style>
#bookshelf {
.bookshelf {
height: calc(100% - 40px);
width: calc(100vw - 80px);
}
.bookshelfRow {
background-image: url(/wood_panels.jpg);

View File

@@ -0,0 +1,159 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div class="fixed bottom-2 right-4 z-40">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
</div>
</div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
</div>
</div>
</template>
<script>
export default {
data() {
return {
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40,
keywordFilterTimeout: null,
scannerParseSubtitle: false,
wrapperClientWidth: 0,
overflowingShelvesRight: {},
overflowingShelvesLeft: {}
}
},
computed: {
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
},
sizeMultiplier() {
return this.bookCoverWidth / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
},
mostRecentPlayed() {
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
audiobooks.sort((a, b) => {
return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate
})
return audiobooks.slice(0, 10)
},
mostRecentAdded() {
var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt)
return audiobooks.slice(0, 10)
},
seriesGroups() {
return this.$store.getters['audiobooks/getSeriesGroups']()
},
recentlyUpdatedSeries() {
var mostRecentTime = 0
var mostRecentSeries = null
this.seriesGroups.forEach((series) => {
if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) {
mostRecentTime = series.lastUpdate
mostRecentSeries = series
}
})
if (!mostRecentSeries) return null
return mostRecentSeries.books
},
booksRecentlyRead() {
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
audiobooks.sort((a, b) => {
return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt
})
return audiobooks.slice(0, 10)
},
shelves() {
var shelves = []
if (this.mostRecentPlayed.length) {
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
}
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
if (this.recentlyUpdatedSeries) {
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
}
if (this.booksRecentlyRead.length) {
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
}
return shelves
}
},
methods: {
increaseSize() {
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
decreaseSize() {
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
await this.$store.dispatch('audiobooks/load')
},
resize() {},
audiobooksUpdated() {},
settingsUpdated(settings) {
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) {
this.selectedSizeIndex = index
this.resize()
}
}
},
scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
mounted() {
window.addEventListener('resize', this.resize)
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.init()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
}
}
</script>

View File

@@ -0,0 +1,153 @@
<template>
<div class="relative">
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
<div v-if="shelf.books" class="flex items-center -mb-2">
<template v-for="entity in shelf.books">
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" />
</template>
</div>
<div v-else-if="shelf.series" class="flex items-center -mb-2">
<template v-for="entity in shelf.series">
<cards-group-card :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" />
</template>
</div>
<div v-else-if="shelf.tags" class="flex items-center -mb-2">
<template v-for="entity in shelf.tags">
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
<cards-group-card :width="bookCoverWidth" :group="entity" />
</nuxt-link>
</template>
</div>
</div>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-8 w-36 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ shelf.label }}</p>
</div>
</div>
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
<div v-show="canScrollLeft && !isScrolling" class="absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
<span class="material-icons text-8xl text-white">chevron_left</span>
</div>
<div v-show="canScrollRight && !isScrolling" class="absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
<span class="material-icons text-8xl text-white">chevron_right</span>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
shelf: {
type: Object,
default: () => {}
},
sizeMultiplier: Number,
bookCoverWidth: Number
},
data() {
return {
canScrollRight: false,
canScrollLeft: false,
isScrolling: false,
scrollTimer: null,
updateTimer: null
}
},
computed: {
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
}
},
methods: {
editBook(audiobook) {
var bookIds = this.shelf.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
scrolled() {
clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(() => {
this.isScrolling = false
this.$nextTick(this.checkCanScroll)
}, 50)
},
scrollLeft() {
if (!this.$refs.shelf) {
return
}
this.isScrolling = true
this.$refs.shelf.scrollLeft = 0
},
scrollRight() {
if (!this.$refs.shelf) {
return
}
this.isScrolling = true
this.$refs.shelf.scrollLeft = 999
},
updatedBookCard() {
clearTimeout(this.updateTimer)
this.updateTimer = setTimeout(() => {
this.$nextTick(this.checkCanScroll)
}, 100)
},
checkCanScroll() {
if (!this.$refs.shelf) {
return
}
var clientWidth = this.$refs.shelf.clientWidth
var scrollWidth = this.$refs.shelf.scrollWidth
var scrollLeft = this.$refs.shelf.scrollLeft
if (scrollWidth > clientWidth) {
this.canScrollRight = scrollLeft === 0
this.canScrollLeft = scrollLeft > 0
} else {
this.canScrollRight = false
this.canScrollLeft = false
}
}
}
}
</script>
<style>
.bookshelfRowCategorized {
scroll-behavior: smooth;
width: calc(100vw - 80px);
background-image: url(/wood_panels.jpg);
}
.bookshelfDividerCategorized {
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(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;
}
.categoryPlacard {
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
letter-spacing: 1px;
}
.shinyBlack {
background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
border-color: rgba(255, 244, 182, 0.6);
border-style: solid;
color: #fce3a6;
}
.book-shelf-arrow-right {
height: calc(100% - 24px);
background: rgb(48, 48, 48);
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
}
.book-shelf-arrow-left {
height: calc(100% - 24px);
background: rgb(48, 48, 48);
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
}
</style>

View File

@@ -1,17 +1,60 @@
<template>
<div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" />
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" />
<span class="px-4 text-sm">by</span>
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" />
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<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>
</div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
<p class="pl-4 font-book text-lg">
{{ selectedSeries }}
</p>
<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>
</div>
</div>
<div class="flex-grow" />
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
<div class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
</div>
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
</div>
</div>
</template>
<template v-else-if="!isHome">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
<div class="flex-grow" />
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
</div>
</div>
</template>
<script>
export default {
props: {
page: String,
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Object,
default: () => {}
},
searchQuery: String,
viewMode: String
},
data() {
return {
settings: {},
@@ -19,11 +62,54 @@ export default {
}
},
computed: {
isGridMode() {
return this.viewMode === 'grid'
},
showSortFilters() {
return this.page === ''
},
numShowing() {
return this.$store.getters['audiobooks/getFiltered']().length
if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.length
} else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
var group = groups.find((g) => g.name === this.selectedSeries)
if (group) return group.books.length
return 0
}
return groups.length
}
},
entityName() {
if (!this.page) return 'Audiobooks'
if (this.page === 'series') return 'Series'
if (this.page === 'collections') return 'Collections'
return ''
},
_keywordFilter: {
get() {
return this.$store.state.audiobooks.keywordFilter
},
set(val) {
this.$store.commit('audiobooks/setKeywordFilter', val)
}
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
searchBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
},
seriesBackArrow() {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
this.$emit('update:selectedSeries', null)
},
updateOrder() {
this.saveSettings()
},
@@ -31,7 +117,6 @@ export default {
this.saveSettings()
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
},
init() {
@@ -56,6 +141,6 @@ export default {
<style>
#toolbar {
box-shadow: 0px 8px 8px #111111aa;
box-shadow: 0px 8px 6px #111111aa;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Settings</p>
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Libraries</p>
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Users</p>
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Backups</p>
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Log</p>
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
routeName() {
return this.$route.name
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
<div 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'">
<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" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" 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="showLibrary ? '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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" 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="paramId === 'series' ? '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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Series</p>
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" 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-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</nuxt-link>
<!-- <nuxt-link to="/library/collections" 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="paramId === 'collections' ? '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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/tags" 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="paramId === 'tags' ? '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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
<!-- <nuxt-link to="/library/authors" 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="paramId === 'authors' ? '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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> -->
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedClassName() {
return ''
},
homePage() {
return this.$route.name === 'library-library'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
showingIssues() {
if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
},
numIssues() {
return this.$store.getters['audiobooks/getAudiobooksWithIssues'].length
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,20 +1,30 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div class="absolute -top-16 left-4">
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary px-4 pb-4 pt-2">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div>
</nuxt-link>
<div class="flex items-center pl-24">
<div>
<h1>
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1>
<p class="text-gray-400 text-sm">by {{ author }}</p>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-lg">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</nuxt-link>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p class="text-base hover:underline cursor-pointer pl-2" @click="filterByAuthor">{{ author }}</p>
</div>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
</div>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" @create="createBookmark" @update="updateBookmark" @delete="deleteBookmark" />
</div>
</template>
@@ -24,7 +34,11 @@ export default {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null
stream: null,
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null
}
},
computed: {
@@ -35,6 +49,14 @@ export default {
user() {
return this.$store.state.user.user
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
bookmarks() {
if (!this.userAudiobook) return []
return this.userAudiobook.bookmarks || []
},
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
@@ -46,9 +68,15 @@ export default {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
audiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
},
title() {
return this.book.title || 'No Title'
},
@@ -60,9 +88,61 @@ export default {
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
}
},
methods: {
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = currentTime
this.showBookmarksModal = true
},
// bookmarkCreated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`${this.$secondsToTimestamp(time)} Bookmarked`)
// }
// },
createBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('create_bookmark', bookmark)
this.showBookmarksModal = false
},
// bookmarkUpdated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`Bookmark @${this.$secondsToTimestamp(time)} Updated`)
// }
// },
updateBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('update_bookmark', bookmark)
this.showBookmarksModal = false
},
selectBookmark(bookmark) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.showBookmarksModal = false
},
deleteBookmark(bookmark) {
this.$root.socket.emit('delete_bookmark', bookmark)
this.showBookmarksModal = false
},
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
@@ -94,7 +174,7 @@ export default {
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
@@ -104,7 +184,7 @@ export default {
streamOpen(stream) {
this.stream = stream
if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream)
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')

View File

@@ -1,9 +1,16 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
<cards-book-cover :audiobook="audiobook" :width="50" />
<div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
</div>
</div>
</template>
@@ -14,7 +21,10 @@ export default {
audiobook: {
type: Object,
default: () => {}
}
},
search: String,
matchKey: String,
matchText: String
},
data() {
return {}
@@ -26,8 +36,35 @@ export default {
title() {
return this.book ? this.book.title : 'No Title'
},
author() {
return this.book ? this.book.author : 'Unknown'
subtitle() {
return this.book ? this.book.subtitle : ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
},
methods: {},
@@ -36,9 +73,9 @@ export default {
</script>
<style>
.searchCardContent {
.audiobookSearchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -0,0 +1,32 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.authorSearchCardContent {
width: calc(100% - 80px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
<div class="perspective">
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
<div class="book book-1 box-shadow-book3d" ref="front"></div>
<div class="title book-1 pointer-events-none" ref="left"></div>
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
<div class="book-back book-1 pointer-events-none">
<div class="text pointer-events-none">
<h3 class="mb-4">Book Back</h3>
<p>
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
src: String,
width: {
type: Number,
default: 200
}
},
data() {
return {
hover: false,
hover2: false,
standardWidth: 200,
standardHeight: 320,
isAttached: true,
pageX: 0,
pageY: 0
}
},
watch: {
src(newVal) {
this.setCover()
},
width(newVal) {
this.init()
},
hover(newVal) {
if (newVal) {
this.unattach()
} else {
this.attach()
}
setTimeout(() => {
this.hover2 = newVal
}, 100)
}
},
computed: {
scaleMultiplier() {
return this.hover2 ? 1.25 : 1
},
scale() {
var scale = this.width / this.standardWidth
return scale
}
},
methods: {
unattach() {
if (this.$refs.card && this.isAttached) {
var bookshelf = document.getElementById('bookshelf')
if (bookshelf) {
var pos = this.$refs.wrapper.getBoundingClientRect()
this.pageX = pos.x
this.pageY = pos.y
document.body.appendChild(this.$refs.card)
this.$refs.card.style.left = this.pageX + 'px'
this.$refs.card.style.top = this.pageY + 'px'
this.$refs.card.style.zIndex = 50
this.isAttached = false
} else if (bookshelf) {
console.log(this.pageX, this.pageY)
this.isAttached = false
}
}
},
attach() {
if (this.$refs.card && !this.isAttached) {
if (this.$refs.wrapper) {
this.isAttached = true
this.$refs.wrapper.appendChild(this.$refs.card)
this.$refs.card.style.left = '0px'
this.$refs.card.style.top = '0px'
}
} else {
console.log('Is attached already', this.isAttached)
}
},
init() {
var standardWidth = this.standardWidth
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
document.documentElement.style.setProperty('--book-d', 40 + 'px')
},
setElBg(el) {
el.style.backgroundImage = `url("${this.src}")`
el.style.backgroundSize = 'cover'
el.style.backgroundPosition = 'center center'
el.style.backgroundRepeat = 'no-repeat'
},
setCover() {
if (this.$refs.front) {
this.setElBg(this.$refs.front)
}
if (this.$refs.bottom) {
this.setElBg(this.$refs.bottom)
this.$refs.bottom.style.backgroundSize = '2000%'
this.$refs.bottom.style.filter = 'blur(1px)'
}
if (this.$refs.left) {
this.setElBg(this.$refs.left)
this.$refs.left.style.backgroundSize = '2000%'
this.$refs.left.style.filter = 'blur(1px)'
}
}
},
mounted() {
this.setCover()
this.init()
}
}
</script>
<style>
/* :root {
--book-w: 200px;
--book-h: 320px;
--book-d: 30px;
--book-wx: 201px;
} */
/*
.wrap {
width: calc(1.1 * var(--book-w));
height: calc(1.1 * var(--book-h));
margin: 0 auto;
}
.perspective {
position: relative;
width: 100%;
height: 100%;
perspective: 600px;
transform-style: preserve-3d;
overflow: hidden;
}
.book-wrap {
height: 100%;
width: 100%;
transform-style: preserve-3d;
transition: 'all ease-out 0.6s';
}
.book {
width: var(--book-w);
height: var(--book-h);
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
background-size: cover;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
cursor: pointer;
}
.title {
content: '';
height: var(--book-h);
width: var(--book-d);
position: absolute;
right: 0;
left: calc(var(--book-wx) * -1);
top: 0;
bottom: 0;
margin: auto;
background: #444;
transform: rotateY(-80deg) translateX(-14px);
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
background-size: 5000%;
filter: blur(1px);
}
.bottom {
content: '';
height: var(--book-d);
width: var(--book-w);
position: absolute;
right: 0;
bottom: var(--book-h);
top: 0;
left: 0;
margin: auto;
background: #444;
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
background-size: 5000%;
filter: blur(1px);
}
.book-back {
width: var(--book-w);
height: var(--book-h);
background-color: #444;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
cursor: pointer;
transform: rotate(180deg) translateZ(-30px) translateX(5px);
}
.book-back .text {
transform: rotateX(180deg);
position: absolute;
bottom: 0px;
padding: 20px;
text-align: left;
font-size: 12px;
}
.book-back .text h3 {
color: #fff;
}
.book-back .text span {
display: block;
margin-bottom: 20px;
color: #fff;
}
.book-wrap.rotate {
transform: rotateY(30deg) rotateX(0deg);
}
.book-wrap.flip {
transform: rotateY(180deg);
} */
</style>

View File

@@ -1,32 +1,55 @@
<template>
<div class="relative">
<!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @click.stop>
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
<div v-show="!isSelectionMode" class="h-full flex items-center justify-center">
<div v-show="showPlayButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<div v-show="showReadButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div>
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
<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 v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :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' }">#{{ volumeNumber }}</p>
</div>
<!-- EBook Icon -->
<div
v-if="showSmallEBookIcon"
class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200"
:style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }"
@click.stop.prevent="clickReadEBook"
>
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
<span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span>
</div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
@@ -53,7 +76,12 @@ export default {
width: {
type: Number,
default: 120
}
},
paddingY: {
type: Number,
default: 16
},
showVolumeNumber: Boolean
},
data() {
return {
@@ -61,8 +89,11 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isNew() {
return this.tags.includes('new')
return this.tags.includes('New')
},
tags() {
return this.audiobook.tags || []
@@ -70,8 +101,14 @@ export default {
audiobookId() {
return this.audiobook.id
},
hasEbook() {
return this.audiobook.numEbooks
},
hasTracks() {
return this.audiobook.numTracks
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
@@ -125,8 +162,29 @@ export default {
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
hasMissingParts() {
return this.audiobook.hasMissingParts
@@ -135,6 +193,8 @@ export default {
return this.audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
@@ -153,6 +213,12 @@ export default {
classes.push('border-2 border-yellow-400')
}
return classes
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
@@ -169,7 +235,8 @@ export default {
this.$root.socket.emit('open_stream', this.audiobookId)
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
// this.$store.commit('showEditModal', this.audiobook)
this.$emit('edit', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
@@ -177,8 +244,10 @@ export default {
e.preventDefault()
this.selectBtnClick()
}
},
clickReadEBook() {
this.$store.commit('showEReader', this.audiobook)
}
},
mounted() {}
}
}
</script>

View File

@@ -4,7 +4,7 @@
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
@@ -76,15 +76,7 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (!this.cover || this.cover === this.placeholderUrl) return ''
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
try {
var url = new URL(this.cover, document.baseURI)
return url.href
} catch (err) {
console.error(err)
return ''
}
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
@@ -106,6 +98,9 @@ export default {
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {

View File

@@ -6,11 +6,11 @@
<div class="flex items-center">
<h1>{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.year || book.first_publish_date }}</p>
<p>{{ book.publishYear }}</p>
</div>
<p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs" v-html="book.description"></p>
<p class="text-gray-500 text-xs">{{ book.description }}</p>
</div>
</div>
</div>
@@ -53,7 +53,7 @@ export default {
}
},
mounted() {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : null
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
<cards-group-cover ref="groupcover" :name="groupName" :group-to="groupTo" :book-items="bookItems" :width="height" :height="height" />
<div v-if="hasValidCovers && !showExperimentalFeatures" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none">
<p class="font-book text-xl">{{ bookItems.length }}</p>
</div>
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap">
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
</div>
</div>
</nuxt-link>
</div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
}
},
data() {
return {
isHovering: false
}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_group() {
return this.group || {}
},
groupType() {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/bookshelf/series?series=${this.groupEncode}`
} else {
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
}
},
height() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookItems() {
return this._group.books || []
},
userAudiobooks() {
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
},
userProgressItems() {
return this.bookItems.map((item) => {
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
return userAudiobook || {}
})
},
groupName() {
return this._group.name || 'No Name'
},
groupType() {
return this._group.type
},
groupEncode() {
return this.$encode(this.groupName)
},
filter() {
return `${this.groupType}.${this.$encode(this.groupName)}`
},
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCard() {
this.isHovering = true
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
},
mouseleaveCard() {
this.isHovering = false
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
},
clickCard() {
this.$emit('click', this.group)
}
}
}
</script>

View File

@@ -0,0 +1,332 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative" @mouseover="mouseoverCover" @mouseleave="mouseleaveCover">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
name: String,
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number,
groupTo: String
},
data() {
return {
noValidCovers: false,
coverDiv: null,
isHovering: false,
coverWrapperEl: null,
coverImageEls: [],
coverWidth: 0,
offsetIncrement: 0,
isFannedOut: false,
isDetached: false,
isAttaching: false
}
},
watch: {
bookItems: {
immediate: true,
handler(newVal) {
if (newVal) {
// ensure wrapper is initialized
this.$nextTick(this.init)
}
}
}
},
computed: {
sizeMultiplier() {
return this.width / 192
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCover() {
if (this.showExperimentalFeatures) this.setHover(true)
},
mouseleaveCover() {
if (this.showExperimentalFeatures) this.setHover(false)
},
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
console.log('Appended to wrapper', this.$refs.wrapper.children)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
setHover(val) {
if (this.isAttaching) return
if (val && !this.isHovering) {
this.detchCoverWrapper()
this.fanOutCovers()
} else if (!val && this.isHovering) {
this.isAttaching = true
this.reverseFan()
setTimeout(() => {
this.attachCoverWrapper()
this.isAttaching = false
}, 100)
}
this.isHovering = val
},
fanOutCovers() {
if (this.coverImageEls.length < 2 || this.isFannedOut) return
this.isFannedOut = true
var fanCoverWidth = this.coverWidth * 0.75
var maximumWidth = window.innerWidth - 80
var totalFanWidth = (this.coverImageEls.length + 1) * fanCoverWidth
// If Fan width is too large, set new fan cover width
if (totalFanWidth > maximumWidth) {
fanCoverWidth = maximumWidth / (this.coverImageEls.length + 1)
}
var fanWidth = (this.coverImageEls.length - 1) * fanCoverWidth
var offsetLeft = (-1 * fanWidth) / 2
var rect = this.$refs.wrapper.getBoundingClientRect()
// If fan is going off page left or right, make adjustment
var leftEdge = rect.left + offsetLeft
var rightEdge = rect.left + rect.width - offsetLeft
if (leftEdge < 0) {
offsetLeft += leftEdge * -1
}
if (rightEdge + 80 > window.innerWidth) {
var difference = rightEdge + 80 - window.innerWidth
offsetLeft -= difference / 2
}
for (let i = 0; i < this.coverImageEls.length; i++) {
var coverEl = this.coverImageEls[i]
// Series name card pop out further
if (i === this.coverImageEls.length - 1) {
offsetLeft += fanCoverWidth * 0.25
}
coverEl.style.transform = `translateX(${offsetLeft}px)`
offsetLeft += fanCoverWidth
var coverOverlay = document.createElement('div')
coverOverlay.className = 'absolute top-0 left-0 w-full h-full hover:bg-black hover:bg-opacity-40 text-white text-opacity-0 hover:text-opacity-100 flex items-center justify-center cursor-pointer'
if (coverEl.dataset.volumeNumber) {
var pEl = document.createElement('p')
pEl.className = 'text-2xl'
pEl.textContent = `#${coverEl.dataset.volumeNumber}`
coverOverlay.appendChild(pEl)
}
if (coverEl.dataset.audiobookId) {
let audiobookId = coverEl.dataset.audiobookId
coverOverlay.addEventListener('click', (e) => {
this.$router.push(`/audiobook/${audiobookId}`)
e.stopPropagation()
e.preventDefault()
})
} else {
// Is Series
coverOverlay.addEventListener('click', (e) => {
this.$router.push(this.groupTo)
e.stopPropagation()
e.preventDefault()
})
}
coverEl.appendChild(coverOverlay)
}
},
reverseFan() {
if (this.coverImageEls.length < 2 || !this.isFannedOut) return
this.isFannedOut = false
for (let i = 0; i < this.coverImageEls.length; i++) {
var coverEl = this.coverImageEls[i]
coverEl.style.transform = 'translateX(0px)'
if (coverEl.lastChild) coverEl.lastChild.remove() // Remove cover overlay
}
},
getCoverUrl(book) {
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
var showCoverBg =
forceCoverBg ||
(await new Promise((resolve) => {
var image = new Image()
image.onload = () => {
var { naturalWidth, naturalHeight } = image
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
resolve(true)
} else {
resolve(false)
}
}
image.onerror = (err) => {
console.error(err)
resolve(false)
}
image.src = src
}))
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = bgCoverWidth + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.style.zIndex = zIndex
imgdiv.dataset.audiobookId = coverData.id
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
// imgdiv.style.transform = 'skew(0deg, 15deg)'
if (showCoverBg) {
var coverbgwrapper = document.createElement('div')
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full bg-primary'
var coverbg = document.createElement('div')
coverbg.className = 'w-full h-full'
coverbg.style.backgroundImage = `url("${src}")`
coverbg.style.backgroundSize = 'cover'
coverbg.style.backgroundPosition = 'center'
coverbg.style.opacity = 0.25
coverbg.style.filter = 'blur(1px)'
coverbgwrapper.appendChild(coverbg)
imgdiv.appendChild(coverbgwrapper)
}
var img = document.createElement('img')
img.src = src
img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
imgdiv.appendChild(img)
return imgdiv
},
createSeriesNameCover(offsetLeft) {
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = this.height / 1.6 + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
imgdiv.style.backgroundColor = '#111'
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
imgdiv.appendChild(innerP)
return imgdiv
},
async init() {
if (this.coverDiv) {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
.filter((b) => b.coverUrl !== '')
if (!validCovers.length) {
this.noValidCovers = true
return
}
this.noValidCovers = false
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
coverWidth = this.height / 1.6
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
}
this.coverWidth = coverWidth
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative'
var coverImageEls = []
var offsetLeft = 0
for (let i = 0; i < validCovers.length; i++) {
offsetLeft = widthPer * i
var zIndex = validCovers.length - i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
outerdiv.appendChild(img)
coverImageEls.push(img)
}
if (this.showExperimentalFeatures) {
var seriesNameCover = this.createSeriesNameCover(offsetLeft)
outerdiv.appendChild(seriesNameCover)
coverImageEls.push(seriesNameCover)
}
this.coverImageEls = coverImageEls
if (this.$refs.wrapper) {
this.coverDiv = outerdiv
this.$refs.wrapper.appendChild(outerdiv)
}
}
},
mounted() {},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
<cards-book-cover :width="24" :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
isHovering: false
}
},
computed: {
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
</a>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
src: String,
width: {
type: Number,
default: 120
},
showOpenNewTab: Boolean
},
data() {
return {
imageFailed: false,
showCoverBg: false,
isHovering: false,
naturalHeight: 0,
naturalWidth: 0
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
cover() {
return this.src
},
sizeMultiplier() {
return this.width / 120
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
this.$refs.coverBg.style.backgroundSize = 'cover'
this.$refs.coverBg.style.backgroundPosition = 'center'
this.$refs.coverBg.style.opacity = 0.25
this.$refs.coverBg.style.filter = 'blur(1px)'
}
},
imageLoaded() {
if (this.$refs.cover) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
series: String,
bookItems: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.seriesSearchCardContent {
width: calc(100% - 80px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">local_offer</span>
</div>
<div class="flex-grow px-2 tagSearchCardContent h-full">
<p class="truncate text-sm">{{ tag }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
tag: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.tagSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -41,10 +41,15 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ snakeToNormal(item) }}</span>
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
</li>
</template>
@@ -81,6 +86,26 @@ export default {
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
@@ -109,14 +134,14 @@ export default {
if (!this.selected) return ''
var parts = this.selected.split('.')
if (parts.length > 1) {
return this.snakeToNormal(parts[1])
return this.$decode(parts[1])
}
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
},
genres() {
return this.$store.state.audiobooks.genres
return this.$store.getters['audiobooks/getGenresUsed']
},
tags() {
return this.$store.state.audiobooks.tags
@@ -124,8 +149,22 @@ export default {
series() {
return this.$store.state.audiobooks.series
},
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
narrators() {
return this.$store.getters['audiobooks/getUniqueNarrators']
},
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() {
return this[this.sublist] || []
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
}
},
methods: {
@@ -134,15 +173,6 @@ export default {
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickOutside() {
if (!this.selectedItemSublist) this.sublist = null
this.showMenu = false

View File

@@ -1,27 +1,57 @@
<template>
<div class="w-64 ml-8 relative">
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
<div class="w-80 ml-6 relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Typing...</p>
<p>Thinking...</p>
</li>
<li v-else-if="isFetching" class="py-2 px-2">
<p>Fetching...</p>
</li>
<li v-else-if="!items.length" class="py-2 px-2">
<li v-else-if="!totalResults" class="py-2 px-2">
<p>No Results</p>
</li>
<template v-else>
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
<template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" />
</template>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults">
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
<cards-author-search-card :author="item.author" />
</nuxt-link>
</li>
</template>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
</nuxt-link>
</li>
</template>
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults">
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
<cards-tag-search-card :tag="item.tag" />
</nuxt-link>
</li>
</template>
</template>
@@ -40,7 +70,10 @@ export default {
isTyping: false,
isFetching: false,
search: null,
items: [],
audiobookResults: [],
authorResults: [],
seriesResults: [],
tagResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -48,9 +81,38 @@ export default {
computed: {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
}
},
methods: {
submitSearch() {
if (!this.search) return
var search = this.search
this.clearResults()
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${search}`)
},
clearResults() {
this.search = null
this.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
clearTimeout(this.searchTimeout)
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
}
})
},
focussed() {
this.isFocused = true
this.showMenu = true
@@ -68,18 +130,24 @@ export default {
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
// Search was canceled
if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.isFetching = false
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
if (!this.showMenu) {
return
}
},
inputUpdate(val) {
clearTimeout(this.searchTimeout)
@@ -90,23 +158,23 @@ export default {
}
this.isTyping = true
this.searchTimeout = setTimeout(() => {
// Canceled search
if (!this.isTyping) return
this.isTyping = false
this.runSearch(val)
}, 1000)
},
clickedOption(option) {
if (option.type === 'audiobook') {
this.$router.push(`/audiobook/${option.data.id}`)
}
}, 750)
},
clickClear() {
if (this.search) {
this.search = null
this.items = []
this.showMenu = false
}
this.clearResults()
}
},
mounted() {}
}
</script>
</script>
<style>
.globalSearchMenu {
max-height: 80vh;
}
</style>

View File

@@ -48,6 +48,10 @@ export default {
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'

View File

@@ -74,10 +74,10 @@ export default {
this.showMenu = false
},
leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4)
this.rateIndex = Math.max(0, this.rateIndex - 1)
},
rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
}
},
mounted() {}

View File

@@ -114,6 +114,9 @@ export default {
this.volume = this.lastValue || 0.5
}
},
toggleMute() {
this.clickVolumeIcon()
},
clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
<modals-modal v-model="show" name="account" :width="800" :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>
@@ -8,20 +8,77 @@
<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="w-full p-8">
<div class="flex py-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
<ui-text-input-with-label v-model="newUser.password" label="Password" type="password" class="mx-2" />
<div class="flex py-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
</div>
<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" />
</div>
</div>
<div class="flex py-2">
<div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :editable="false" :items="accountTypes" />
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
</div>
<div class="flex-grow" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" />
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
</div>
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
<p class="text-lg mb-2 font-semibold">Permissions</p>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Download</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Update</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Delete</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Upload</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</div>
</div>
<div class="flex pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
@@ -68,15 +125,71 @@ export default {
}
},
title() {
return this.isNew ? 'Add New Account' : `Update "${(this.account || {}).username}" Account`
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
},
isEditingRoot() {
return this.account && this.account.type === 'root'
},
libraries() {
return this.$store.state.libraries.libraries
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
}
},
methods: {
accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = []
}
},
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
return
}
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
this.$toast.error('Must select at least one library')
return
}
if (this.isNew) {
this.submitCreateAccount()
} else {
this.submitUpdateAccount()
}
},
submitUpdateAccount() {
var account = { ...this.newUser }
if (!account.password || account.type === 'root') {
delete account.password
}
if (account.type === 'root' && !account.isActive) return
this.processing = true
console.log('Calling update', account)
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
this.$toast.success('Account updated')
this.show = false
}
})
.catch((error) => {
this.processing = false
console.error('Failed to update account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to update account')
})
},
submitCreateAccount() {
if (!this.newUser.password) {
this.$toast.error('Must have a password, only root user can have an empty password')
return
@@ -84,44 +197,61 @@ export default {
var account = { ...this.newUser }
this.processing = true
if (this.isNew) {
this.$axios
.$post('/api/user', account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`)
} else {
console.log('New Account:', data.user)
this.$toast.success('New account created')
this.show = false
}
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$axios
.$post('/api/user', account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`)
} else {
this.$toast.success('New account created')
})
}
this.show = false
}
})
.catch((error) => {
this.processing = false
console.error('Failed to create account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to create account')
})
},
toggleActive() {
this.newUser.isActive = !this.newUser.isActive
},
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin'
}
},
init() {
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
}
} else {
this.newUser = {
username: null,
password: null,
type: 'user',
isActive: true
isActive: true,
permissions: {
download: true,
update: false,
delete: false,
upload: false,
accessAllLibraries: true
},
librariesAccessible: []
}
}
}

View File

@@ -0,0 +1,122 @@
<template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div class="w-full h-full px-6 py-6" v-show="showBookmarkTitleInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click="showBookmarkTitleInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
<div class="flex-grow" />
<p class="text-xl font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
<form @submit.prevent="submitBookmark">
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
<div class="flex justify-end mt-6">
<ui-btn color="success" class="w-1/2" type="submit">{{ selectedBookmark ? 'Update' : 'Create' }} Bookmark</ui-btn>
</div>
</form>
</div>
<div class="w-full h-full" v-show="!showBookmarkTitleInput">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
</template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
</div>
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click="createBookmark">
<span class="material-icons">add</span>
<p class="text-base pl-2">Create Bookmark</p>
<p class="text-sm font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
bookmarks: {
type: Array,
default: () => []
},
currentTime: {
type: Number,
default: 0
},
audiobookId: String
},
data() {
return {
selectedBookmark: null,
showBookmarkTitleInput: false,
newBookmarkTitle: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.showBookmarkTitleInput = false
this.newBookmarkTitle = ''
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
}
},
methods: {
editBookmark(bm) {
this.selectedBookmark = bm
this.newBookmarkTitle = bm.title
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$emit('delete', bookmark)
},
clickBookmark(bm) {
this.$emit('select', bm)
},
createBookmark() {
this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true
},
submitBookmark() {
if (this.selectedBookmark) {
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
var bookmark = { ...this.selectedBookmark }
bookmark.audiobookId = this.audiobookId
bookmark.title = this.newBookmarkTitle
this.$emit('update', bookmark)
}
} else {
var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
}
this.$emit('create', bookmark)
}
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
}
}
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }}
<span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
</template>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
chapters: {
type: Array,
default: () => []
},
currentChapter: {
type: Object,
default: () => null
}
},
data() {
return {}
},
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
}
},
methods: {
clickChapter(chap) {
this.$emit('select', chap)
},
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
}
}
}
}
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<modals-modal v-model="show" name="edit-library" :width="700" :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 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">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -1,18 +1,26 @@
<template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<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 class="absolute -top-10 left-0 w-full flex">
<template v-for="tab in tabs">
<template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<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 class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</keep-alive>
</div>
</modals-modal>
@@ -22,7 +30,6 @@
export default {
data() {
return {
selectedTab: 'details',
processing: false,
audiobook: null,
fetchOnShow: false,
@@ -37,20 +44,30 @@ export default {
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
},
{
id: 'tracks',
title: 'Tracks',
component: 'modals-edit-tabs-tracks'
},
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'files',
title: 'Files',
component: 'modals-edit-tabs-files'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
}
]
}
@@ -59,6 +76,16 @@ export default {
show: {
handler(newVal) {
if (newVal) {
var availableTabIds = this.availableTabs.map((tab) => tab.id)
if (!availableTabIds.length) {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull()
return
@@ -66,6 +93,9 @@ export default {
this.fetchOnShow = false
this.audiobook = null
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
}
}
}
@@ -79,10 +109,44 @@ export default {
this.$store.commit('setShowEditModal', val)
}
},
selectedTab: {
get() {
return this.$store.state.editModalTab
},
set(val) {
this.$store.commit('setEditModalTab', val)
}
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
if (tab.id === 'match' && this.showExperimentalFeatures) return true
return false
})
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
},
@@ -94,11 +158,49 @@ export default {
},
title() {
return this.book.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
},
canGoNext() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1
}
},
methods: {
goPrevBook() {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId)
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', prevBookId)
}
},
goNextBook() {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId)
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', nextBookId)
}
},
selectTab(tab) {
this.selectedTab = tab
if (this.availableTabs.find((t) => t.id === tab)) {
this.selectedTab = tab
}
},
audiobookUpdated() {
if (!this.show) this.fetchOnShow = true
@@ -112,14 +214,33 @@ export default {
},
async fetchFull() {
try {
this.processing = true
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
this.processing = false
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
this.processing = false
this.show = false
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextBook()
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
this.goPrevBook()
}
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
}
},
mounted() {}
mounted() {},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
@@ -18,6 +18,7 @@
<script>
export default {
props: {
name: String,
value: Boolean,
processing: Boolean,
persistent: {
@@ -31,6 +32,10 @@ export default {
height: {
type: [String, Number],
default: 'unset'
},
contentMarginTop: {
type: Number,
default: 50
}
},
data() {
@@ -69,23 +74,37 @@ export default {
}
},
methods: {
clickClose() {
this.show = false
},
clickBg(vm, ev) {
if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
},
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name)
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', null)
}
},
mounted() {

View File

@@ -0,0 +1,45 @@
<template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div class="flex-grow overflow-hidden">
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
</div>
<div class="h-full flex items-center w-16 justify-end">
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
</div>
</div>
</template>
<script>
export default {
props: {
bookmark: {
type: Object,
default: () => {}
},
highlight: Boolean
},
data() {
return {
isHovering: false
}
},
computed: {},
methods: {
click() {
this.$emit('click', this.bookmark)
},
deleteClick() {
this.$emit('delete', this.bookmark)
},
editClick() {
this.$emit('edit', this.bookmark)
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<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>
<template v-for="chapter in chapters">
<tr :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>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
chapters: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
}
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<div class="relative">
<cards-book-cover :audiobook="audiobook" />
@@ -12,25 +12,28 @@
</div>
</div>
<div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm">
<div class="flex items-center">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
</div>
</form>
</form>
</div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image(s)</p>
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover.localPath" class="h-full w-full object-contain" />
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
</div>
</div>
</template>
@@ -50,25 +53,32 @@
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover" class="h-full w-full object-contain" />
</div>
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
</div>
</template>
</div>
</div>
</div>
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
<p class="text-lg">Preview Cover</p>
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
<div class="flex justify-center py-4">
<cards-preview-cover :src="previewUpload" :width="240" />
</div>
<div class="absolute bottom-0 right-0 flex py-4 px-5">
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
</div>
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
@@ -79,12 +89,15 @@ export default {
},
data() {
return {
processingUpload: false,
searchTitle: null,
searchAuthor: null,
imageUrl: null,
coversFound: [],
hasSearched: false,
showLocalCovers: false
showLocalCovers: false,
previewUpload: null,
selectedFile: null
}
},
watch: {
@@ -106,32 +119,82 @@ export default {
this.$emit('update:processing', val)
}
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
audiobookPath() {
return this.audiobook ? this.audiobook.path : null
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
userToken() {
return this.$store.getters['user/getToken']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = Path.join('local', _file.path)
var imgRelPath = _file.path.replace(this.audiobookPath, '')
_file.localPath = `/s/book/${this.audiobookId}${imgRelPath}`
return _file
})
}
},
methods: {
submitCoverUpload() {
this.processingUpload = true
var form = new FormData()
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview()
}
this.processingUpload = false
})
.catch((error) => {
console.error('Failed', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Oops, something went wrong...')
}
this.processingUpload = false
})
},
resetCoverPreview() {
if (this.$refs.fileInput) {
this.$refs.fileInput.reset()
}
this.previewUpload = null
this.selectedFile = null
},
fileUploadSelected(file) {
this.previewUpload = URL.createObjectURL(file)
this.selectedFile = file
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
this.searchAuthor = this.book.authorFL || ''
},
removeCover() {
if (!this.book.cover) {
@@ -150,20 +213,39 @@ export default {
}
this.isProcessing = true
const updatePayload = {
book: {
cover: cover
var success = false
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else {
// Update local cover url
const updatePayload = {
book: {
cover: cover
}
}
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
if (success) {
this.$toast.success('Update Successful')
this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
@@ -181,8 +263,24 @@ export default {
this.isProcessing = false
this.hasSearched = true
},
setCover(cover) {
this.updateCover(cover)
setCover(coverFile) {
this.isProcessing = true
this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {
this.$toast.success(data)
}
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
this.isProcessing = false
})
}
}
}

View File

@@ -1,50 +1,73 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
</div>
<form @submit.prevent="submitForm">
<ui-text-input-with-label v-model="details.title" label="Title" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div> -->
<ui-text-input-with-label v-model="details.title" label="Title" />
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label 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 v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div>
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</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="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex-grow" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<div class="flex py-4">
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
<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-tooltip>
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</form>
</div>
@@ -63,15 +86,20 @@ export default {
return {
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
genres: []
},
newTags: [],
resettingProgress: false
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
rescanning: false
}
},
watch: {
@@ -91,17 +119,20 @@ export default {
this.$emit('update:processing', val)
}
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
genres() {
return this.$store.state.audiobooks.genres
@@ -111,9 +142,51 @@ export default {
},
series() {
return this.$store.state.audiobooks.series
},
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() {
this.rescanning = true
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this.$root.socket.emit('scan_audiobook', this.audiobookId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
async submitForm() {
if (this.isProcessing) {
return
@@ -136,8 +209,10 @@ export default {
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
@@ -162,7 +237,7 @@ export default {
}
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?`)) {
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/audiobook/${this.audiobookId}`)
@@ -177,7 +252,41 @@ export default {
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(this.$refs.formWrapper)
})
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
// this.init()
this.setResizeObserver()
}
}
</script>
</script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>

View File

@@ -1,24 +1,52 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full border border-black-200 p-4 my-4">
<p class="text-center text-lg mb-4 pb-8 border-b border-black-200">
<span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to 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 10 minutes then get deleted.
</p>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads 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.<br />Download will timeout after 15 minutes.</p>
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<p class="text-lg">Single audio file</p>
<div>
<p class="text-lg">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>
</div>
<div class="flex-grow" />
<div>
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
<p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p>
<p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
<p v-else>Zip 1 File</p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</div>
<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">
@@ -51,7 +79,7 @@ export default {
}
},
watch: {
singleAudioDownloadPending(newVal) {
singleDownloadStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
@@ -61,31 +89,48 @@ export default {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
_audiobook() {
return this.audiobook || {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleAudioDownloadPending() {
return this.singleAudioDownload && this.singleAudioDownload.isPending
singleDownloadStatus() {
return this.singleAudioDownload ? this.singleAudioDownload.status : false
},
singleAudioDownloadFailed() {
return this.singleAudioDownload && this.singleAudioDownload.isFailed
zipDownload() {
return this.downloads.find((d) => d.type === 'zip')
},
singleAudioDownloadReady() {
return this.singleAudioDownload && this.singleAudioDownload.isReady
zipDownloadStatus() {
return this.zipDownload ? this.zipDownload.status : false
},
singleAudioDownloadExpired() {
return this.singleAudioDownload && this.singleAudioDownload.isExpired
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
},
zipBundleDownload() {
return this.downloads.find((d) => d.type === 'zipBundle')
singleTrackPath() {
if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
},
showM4bDownload() {
return !this._audiobook.isMissing && !this._audiobook.isIncomplete && this._audiobook.tracks.length
}
},
methods: {
startSingleAudioDownload() {
console.log('Download request received', this.audiobook)
startZipDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
@@ -94,14 +139,30 @@ export default {
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio'
type: 'zip'
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress() {
var downloadId = this.singleAudioDownload.id
startSingleAudioDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio',
includeMetadata: true,
includeCover: true
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = this.singleAudioDownload.filename
var filename = download.filename
this.isDownloading = true

View File

@@ -0,0 +1,21 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-all-files-table :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
methods: {}
}
</script>

View File

@@ -1,15 +1,17 @@
<template>
<div class="w-full h-full overflow-hidden">
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
<div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" />
</div>
<div class="w-72 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 class="flex-grow" />
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
@@ -23,6 +25,51 @@
<cards-book-match-card :key="index" :book="res" @select="selectMatch" />
</template>
</div>
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-2">
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-3">Update Book Details</p>
</div>
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" />
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" />
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" />
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" />
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">Update</ui-btn>
</div>
</form>
</div>
</div>
</template>
@@ -41,9 +88,30 @@ export default {
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'best',
providers: [
{
text: 'Google Books',
value: 'google'
},
{
text: 'Open Library',
value: 'openlibrary'
}
],
provider: 'google',
searchResults: [],
hasSearched: false
hasSearched: false,
selectedMatch: null,
selectedMatchUsage: {
title: true,
subtitle: true,
cover: true,
author: true,
description: true,
isbn: true,
publisher: true,
publishYear: true
}
}
},
watch: {
@@ -95,6 +163,18 @@ export default {
this.hasSearched = true
},
init() {
this.selectedMatch = null
this.selectedMatchUsage = {
title: true,
subtitle: true,
cover: true,
author: true,
description: true,
isbn: true,
publisher: true,
publishYear: true
}
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
@@ -107,31 +187,63 @@ export default {
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || ''
this.searchAuthor = this.audiobook.book.authorFL || ''
},
async selectMatch(match) {
selectMatch(match) {
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
updatePayload[key] = this.selectedMatch[key]
}
}
return updatePayload
},
async submitMatchUpdate() {
var updatePayload = this.buildMatchUpdatePayload()
if (!Object.keys(updatePayload).length) {
return
}
this.isProcessing = true
const updatePayload = {
book: {}
if (updatePayload.cover) {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Cover Updated')
} else {
this.$toast.error('Book Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
}
if (match.cover) {
updatePayload.book.cover = match.cover
if (Object.keys(updatePayload).length) {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
if (success) {
this.$toast.success('Book Details Updated')
this.selectedMatch = null
this.$emit('selectTab', 'details')
} else {
this.$toast.error('Book Details Failed to Update')
}
} else {
this.selectedMatch = null
}
if (match.title) {
updatePayload.book.title = match.title
}
if (match.description) {
updatePayload.book.description = match.description
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
this.$toast.success('Update Successful')
this.$emit('close')
}
}
}
}

View File

@@ -1,34 +1,44 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-4">
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
</template>
</table>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
</template>
@@ -43,7 +53,7 @@ export default {
data() {
return {
tracks: null,
audioFiles: null
showFullPath: false
}
},
watch: {
@@ -54,12 +64,46 @@ export default {
}
}
},
computed: {},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks
console.log('INIT', this.audiobook)
}
}
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="w-full h-full px-4 py-2 mb-12">
<div class="flex items-center py-1 mb-2">
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<ui-text-input-with-label v-model="name" label="Library Name" />
<div class="w-full py-4">
<p class="px-1 text-sm font-semibold">Folders</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center">
<div class="flex-grow" />
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
</div>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
name: '',
folders: [],
showDirectoryPicker: false
}
},
computed: {
title() {
if (this.showDirectoryPicker) return 'Choose a Folder'
return ''
},
folderPaths() {
return this.folders.map((f) => f.fullPath)
},
disableSubmit() {
if (!this.library) {
return false
}
var newfolderpaths = this.folderPaths.join(',')
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
console.log(newfolderpaths)
console.log(origfolderpaths)
console.log(newfolderpaths === origfolderpaths)
return newfolderpaths === origfolderpaths && this.name === this.library.name
}
},
methods: {
removeFolder(folder) {
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
},
backArrowPress() {
if (this.showDirectoryPicker) {
this.showDirectoryPicker = false
}
},
init() {
this.name = this.library ? this.library.name : ''
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.showDirectoryPicker = false
},
selectFolder(fullPath) {
this.folders.push({ fullPath })
this.showDirectoryPicker = false
},
submit() {
if (this.library) {
this.updateLibrary()
} else {
this.createLibrary()
}
},
updateLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$patch(`/api/library/${this.library.id}`, newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" updated successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to update library')
}
this.$emit('update:processing', false)
})
},
createLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$post('/api/library', newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" created successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to create library')
}
this.$emit('update:processing', false)
})
}
},
mounted() {
console.log('Mounted edit library')
this.init()
}
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="w-full h-full">
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
</div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
<div class="w-1/2 border-r border-bg">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p>
</div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div>
</div>
<div class="w-1/2">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div>
</div>
</div>
<div v-else-if="loadingFolders" class="py-12 text-center">
<p>Loading folders...</p>
</div>
<div v-else class="py-12 text-center">
<p class="text-lg mb-2">No Folders Available</p>
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
<!-- <div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div> -->
</div>
</div>
</template>
<script>
export default {
props: {
paths: {
type: Array,
default: () => []
}
},
data() {
return {
loadingFolders: false,
allFolders: [],
directories: [],
selectedPath: '',
selectedFullPath: '',
subdirs: [],
level: 0,
currentDir: null,
previousDir: null
}
},
computed: {
_directories() {
return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath
var classes = []
if (isSelected) classes.push('dir-selected')
if (isUsed) classes.push('dir-used')
return {
isUsed,
isSelected,
className: classes.join(' '),
...d
}
})
},
_subdirs() {
return this.subdirs.map((d) => {
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var classes = []
if (isUsed) classes.push('dir-used')
return {
isUsed,
className: classes.join(' '),
...d
}
})
}
},
methods: {
goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1)
var prev = splitPaths.slice(0, -1).join('\\')
var currDirs = this.allFolders
for (let i = 0; i < splitPaths.length; i++) {
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) {
this.directories = currDirs
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
}
},
selectDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.subdirs = dir.dirs
},
selectSubDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.directories = this.subdirs
this.subdirs = dir.dirs
},
selectFolder() {
if (!this.selectedPath) {
console.error('No Selected path')
return
}
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return
}
this.$emit('select', this.selectedFullPath)
this.selectedPath = ''
this.selectedFullPath = ''
},
async init() {
this.loadingFolders = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
this.loadingFolders = false
this.directories = this.allFolders
this.subdirs = []
this.selectedPath = ''
this.selectedFullPath = ''
}
},
mounted() {
console.log('folder chooser mounted')
this.init()
}
}
</script>
<style>
.dir-item.dir-selected {
background-color: rgba(255, 255, 255, 0.1);
}
.dir-item.dir-used {
background-color: rgba(255, 25, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => {}
},
selected: Boolean,
showEdit: Boolean,
dragging: Boolean
},
data() {
return {
mouseover: false,
isDeleting: false
}
},
computed: {
isHovering() {
return this.mouseover && !this.dragging
},
isMain() {
return this.library.id === 'main'
},
libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
},
canEdit() {
return this.$store.getters['user/getIsRoot']
},
canDelete() {
return this.$store.getters['user/getIsRoot']
},
canScan() {
return this.$store.getters['user/getIsRoot']
}
},
methods: {
itemClicked() {
// this.$emit('click', this.library)
},
editClick() {
this.$emit('edit', this.library)
},
scan() {
this.$root.socket.emit('scan', this.library.id)
},
deleteClick() {
if (this.isMain) return
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
this.isDeleting = true
this.$axios
.$delete(`/api/library/${this.library.id}`)
.then((data) => {
this.isDeleting = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Library deleted')
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error('Failed to delete library')
this.isDeleting = false
})
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
persistent: {
type: Boolean,
default: true
},
width: {
type: [String, Number],
default: 500
},
height: {
type: [String, Number],
default: 'unset'
}
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
modalHeight() {
if (typeof this.height === 'string') {
return this.height
} else {
return this.height + 'px'
}
},
modalWidth() {
return typeof this.width === 'string' ? this.width : this.width + 'px'
}
},
methods: {
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
}
},
mounted() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1
this.el.remove()
}
}
</script>

View File

@@ -0,0 +1,238 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
>: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
<script>
import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
})
export default {
props: {
url: String
},
data() {
return {
loading: false,
pages: null,
filesObject: null,
mainImg: null,
page: 0,
numPages: 0,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null
}
},
watch: {
url: {
immediate: true,
handler() {
this.extract()
}
}
},
computed: {
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
},
canGoPrev() {
return this.page > 0
}
},
methods: {
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
},
next() {
if (!this.canGoNext) return
this.setPage(this.page + 1)
},
prev() {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
return
}
var filename = this.pages[index]
this.page = index
return this.extractFile(filename)
},
setLoadTimeout() {
this.loadTimeout = setTimeout(() => {
this.loading = true
}, 150)
},
extractFile(filename) {
return new Promise(async (resolve) => {
this.setLoadTimeout()
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.mainImg = e.target.result
this.loading = false
resolve()
}
reader.onerror = (e) => {
console.error(e)
this.$toast.error('Read page file failed')
this.loading = false
resolve()
}
reader.readAsDataURL(file)
clearTimeout(this.loadTimeout)
})
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject()
var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames)
var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')
if (xmlFile) await this.extractXmlFile(xmlFile)
this.numPages = this.pages.length
if (this.pages.length) {
this.loading = false
await this.setPage(0)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
this.loading = false
}
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.comicMetadata = this.$xmlToJson(e.target.result)
console.log('Metadata', this.comicMetadata)
}
reader.onerror = (e) => {
console.error(e)
}
reader.readAsText(file)
} catch (error) {
console.error(error)
}
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,
filename
}
} else {
return {
index: Number(numbersinpath[numbersinpath.length - 1]),
filename
}
}
},
parseFilenames(filenames) {
const acceptableImages = ['.jpeg', '.jpg', '.png']
var imageFiles = filenames.filter((f) => {
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
})
var imageFileObjs = imageFiles.map((img) => {
return this.parseImageFilename(img)
})
var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)
var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)
var noNumImages = imageFileObjs.filter((i) => i.index < 0)
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages
}
},
mounted() {},
beforeDestroy() {}
}
</script>
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
</template>
<script>
import ePub from 'epubjs'
export default {
props: {
url: String
},
data() {
return {
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
prev() {
if (this.rendition) {
this.rendition.prev()
}
},
next() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
</div>
</template>
<script>
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
},
data() {
return {}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
if (!iframe) return
let doc = iframe.contentDocument
if (!doc) return
let style = doc.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
doc.head.appendChild(style)
},
handleIFrameHeight(iFrame) {
const isElement = (obj) => !!(obj && obj.nodeType === 1)
var body = iFrame.contentWindow.document.body,
html = iFrame.contentWindow.document.documentElement
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
setTimeout(() => {
let lastchild = body.lastElementChild
let lastEle = body.lastChild
let itemAs = body.querySelectorAll('a')
let itemPs = body.querySelectorAll('p')
let lastItemA = itemAs[itemAs.length - 1]
let lastItemP = itemPs[itemPs.length - 1]
let lastItem
if (isElement(lastItemA) && isElement(lastItemP)) {
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
lastItem = lastItemA
} else {
lastItem = lastItemP
}
}
if (!lastchild && !lastItem && !lastEle) return
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
let nodeHeight = 0
if (lastEle.nodeType === 3 && document.createRange) {
let range = document.createRange()
range.selectNodeContents(lastEle)
if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect()
if (rect) {
nodeHeight = rect.bottom - rect.top
}
}
}
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
}, 500)
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
var file_content = event.target.result
let mobiFile = new MobiParser(file_content)
let content = await mobiFile.render()
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
var anchoredDoc = htmlParser.getAnchoredDoc()
let iFrame = document.getElementsByTagName('iframe')[0]
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
// Add css
let style = iFrame.contentDocument.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
iFrame.contentDocument.head.appendChild(style)
this.handleIFrameHeight(iFrame)
}
reader.readAsArrayBuffer(buff)
}
},
mounted() {
this.initMobi()
}
}
</script>
<style>
.ebook-viewer {
height: calc(100% - 96px);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="w-full h-full pt-20 relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div>
<!-- <div class="text-center py-2 text-lg">
<p>{{ page }} / {{ numPages }}</p>
</div> -->
</div>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
},
data() {
return {
rotate: 0,
loadedRatio: 0,
page: 1,
numPages: 0
}
},
computed: {
pdfWidth() {
return this.pdfHeight * 0.6667
},
pdfHeight() {
return window.innerHeight - 120
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
}
},
methods: {
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
},
next() {
if (this.page >= this.numPages) return
this.page++
},
error(err) {
console.error(err)
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
</template>
<script>
export default {
data() {
return {
ebookType: '',
ebookUrl: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEReader
},
set(val) {
this.$store.commit('setShowEReader', val)
}
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
abTitle() {
return this.selectedAudiobook.book.title
},
abAuthor() {
return this.selectedAudiobook.book.author
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
},
libraryId() {
return this.selectedAudiobook.libraryId
},
folderId() {
return this.selectedAudiobook.folderId
},
ebooks() {
return this.selectedAudiobook.ebooks || []
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
},
pdfEbook() {
return this.ebooks.find((eb) => eb.ext === '.pdf')
},
comicEbook() {
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
},
userToken() {
return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
}
},
methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
},
init() {
this.registerListeners()
if (this.selectedAudiobookFile) {
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
if (this.selectedAudiobookFile.ext === '.pdf') {
this.ebookType = 'pdf'
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
this.ebookType = 'mobi'
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
this.ebookType = 'comic'
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
} else if (this.comicEbook) {
this.ebookType = 'comic'
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
}
},
close() {
this.unregisterListeners()
this.show = false
}
},
mounted() {
if (this.show) this.init()
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer">
<p class="pr-4">Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
</div>
<div class="w-full">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="file in allFiles">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
otherFiles() {
return this.audiobook.otherFiles || []
},
audioFiles() {
return this.audiobook.audioFiles || []
},
audioFilesCleaned() {
return this.audioFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: 'audio'
}
})
},
otherFilesCleaned() {
return this.otherFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: af.filetype
}
})
},
allFiles() {
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
}
},
methods: {
getRelativePath(path) {
var filePath = path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
},
mounted() {}
}
</script>

View File

@@ -4,7 +4,7 @@
<p class="pr-4">Other Audio Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</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' : ''">
@@ -56,7 +56,11 @@ export default {
showTracks: false
}
},
computed: {},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks

View File

@@ -0,0 +1,190 @@
<template>
<div class="text-center mt-4">
<div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">Upload Backup</ui-file-input>
<div class="flex-grow" />
<ui-btn :loading="isBackingUp" @click="clickCreateBackup">Create Backup</ui-btn>
</div>
<div class="relative">
<table id="backups">
<tr>
<th>File</th>
<th class="w-56">Datetime</th>
<th class="w-28">Size</th>
<th class="w-36"></th>
</tr>
<tr v-for="backup in backups" :key="backup.id">
<td>
<p class="truncate">/{{ backup.path.replace(/\\/g, '/') }}</p>
</td>
<td class="font-sans">{{ backup.datePretty }}</td>
<td class="font-mono">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex items-center justify-center">
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-0.5 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<!-- <span class="material-icons text-xl hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="downloadBackup">download</span> -->
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
</div>
</td>
</tr>
<tr v-if="!backups.length" class="staticrow">
<td colspan="4" class="text-lg">No Backups</td>
</tr>
</table>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
<prompt-dialog v-model="showConfirmApply" :width="675">
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-error text-lg font-semibold">Important Notice!</p>
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
<div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmApply = false">Nevermind</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" @click="confirm">Apply Backup</ui-btn>
</div>
</div>
</prompt-dialog>
</div>
</template>
<script>
export default {
data() {
return {
showConfirmApply: false,
selectedBackup: null,
isBackingUp: false,
processing: false
}
},
computed: {
backups() {
return this.$store.state.backups || []
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
confirm() {
this.showConfirmApply = false
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
},
deleteBackupClick(backup) {
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
this.processing = true
this.$axios
.$delete(`/api/backup/${backup.id}`)
.then((backups) => {
console.log('Backup deleted', backups)
this.$store.commit('setBackups', backups)
this.$toast.success(`Backup deleted`)
this.processing = false
})
.catch((error) => {
console.error(error)
this.$toast.error('Failed to delete backup')
this.processing = false
})
}
},
applyBackupComplete(success) {
if (success) {
// this.$toast.success('Backup Applied, refresh the page')
location.replace('/config/backups?backup=1')
} else {
this.$toast.error('Failed to apply backup')
}
},
applyBackup(backup) {
this.selectedBackup = backup
this.showConfirmApply = true
},
backupComplete(backups) {
this.isBackingUp = false
if (backups) {
this.$toast.success('Backup Successful')
this.$store.commit('setBackups', backups)
} else this.$toast.error('Backup Failed')
},
clickCreateBackup() {
this.isBackingUp = true
this.$root.socket.once('backup_complete', this.backupComplete)
this.$root.socket.emit('create_backup')
},
backupUploaded(file) {
var form = new FormData()
form.set('file', file)
this.processing = true
this.$axios
.$post('/api/backup/upload', form)
.then((result) => {
console.log('Upload backup result', result)
this.$store.commit('setBackups', result)
this.$toast.success('Backup upload success')
this.processing = false
})
.catch((error) => {
console.error(error)
var errorMessage = error.response && error.response.data ? error.response.data : 'Failed to upload backup'
this.$toast.error(errorMessage)
this.processing = false
})
}
},
mounted() {
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
}
}
}
</script>
<style>
#backups {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
}
#backups td,
#backups th {
border: 1px solid #2e2e2e;
padding: 8px 8px;
text-align: left;
}
#backups tr.staticrow td {
text-align: center;
}
#backups tr:nth-child(even) {
background-color: #3a3a3a;
}
#backups tr:not(.staticrow):hover {
background-color: #444;
}
#backups th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div id="librariesTable" class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Libraries</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<draggable v-model="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<!-- <transition-group type="transition" :name="!drag ? 'flip-list' : null"> -->
<template v-for="library in libraryCopies">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" class="item" />
</template>
<!-- </transition-group> -->
</draggable>
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
data() {
return {
libraryCopies: [],
currentOrder: [],
showLibraryModal: false,
selectedLibrary: null,
drag: false,
dragOptions: {
animation: 200,
group: 'description',
ghostClass: 'ghost'
},
orderTimeout: null
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
}
},
methods: {
startDrag() {
this.drag = true
clearTimeout(this.orderTimeout)
},
endDrag() {
this.drag = false
this.checkOrder()
console.log('DRAG END')
},
checkOrder() {
clearTimeout(this.orderTimeout)
this.orderTimeout = setTimeout(() => {
this.saveOrder()
}, 500)
},
saveOrder() {
var _newOrder = 1
var currOrder = this.libraries.map((lib) => lib.id).join(',')
var libraryOrderData = this.libraryCopies.map((library) => {
return {
newOrder: _newOrder++,
oldOrder: library.displayOrder,
id: library.id
}
})
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) {
this.$axios.$patch('/api/libraries/order', libraryOrderData).then((libraries) => {
if (libraries && libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries)
}
})
}
},
async setLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
},
clickAddLibrary() {
this.selectedLibrary = null
this.showLibraryModal = true
},
editLibrary(library) {
this.selectedLibrary = library
this.showLibraryModal = true
},
init() {
this.libraryCopies = this.libraries.map((lib) => {
return { ...lib }
})
},
librariesUpdated() {
this.init()
}
},
mounted() {
this.$store.commit('libraries/addListener', { id: 'libraries-table', meth: this.librariesUpdated })
this.init()
},
beforeDestroy() {
this.$store.commit('libraries/removeListener', 'libraries-table')
}
}
</script>

View File

@@ -2,11 +2,14 @@
<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">Other Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" />
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> -->
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
@@ -15,16 +18,23 @@
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Path</th>
<th class="text-left">Filetype</th>
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr>
<template v-for="file in files">
<template v-for="file in otherFilesCleaned">
<tr :key="file.path">
<td class="font-book pl-2">
{{ file.path }}
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
<div class="flex items-center">
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
<p>{{ file.filetype }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@@ -41,15 +51,52 @@ export default {
type: Array,
default: () => []
},
audiobookId: String
audiobook: {
type: Object,
default: () => null
}
},
data() {
return {
showFiles: false
showFiles: false,
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
otherFilesCleaned() {
return this.files.map((file) => {
var filePath = file.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...file,
relativePath: filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
computed: {},
methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() {
this.showFiles = !this.showFiles
}

View File

@@ -2,9 +2,13 @@
<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">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</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' : ''">
@@ -15,25 +19,27 @@
<div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="w-10">#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th class="text-left w-20">Size</th>
<th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="track in tracks">
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
@@ -49,14 +55,48 @@ export default {
type: Array,
default: () => []
},
audiobookId: String
audiobook: {
type: Object,
default: () => null
}
},
data() {
return {
showTracks: false
showTracks: false,
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
computed: {},
methods: {
clickBar() {
this.showTracks = !this.showTracks

View File

@@ -0,0 +1,215 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div class="text-center">
<table id="accounts">
<tr>
<th>Username</th>
<th class="w-20">Type</th>
<th>Activity</th>
<th class="w-32">Last Seen</th>
<th class="w-32">Created</th>
<th class="w-32"></th>
</tr>
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
<td>
<div class="flex items-center">
<widgets-online-indicator :value="!!usersOnline[user.id]" />
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</div>
</td>
<td class="text-sm">{{ user.type }}</td>
<td>
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
</div>
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
</div>
</td>
<td class="text-xs font-mono">
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
{{ $dateDistanceFromNow(user.lastSeen) }}
</ui-tooltip>
</td>
<td class="text-xs font-mono">
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
</ui-tooltip>
</td>
<td class="py-0">
<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> -->
<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)">
<span class="material-icons text-base">edit</span>
</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)">
<span class="material-icons text-base">delete</span>
</div>
</div>
</td>
</tr>
</table>
</div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
<script>
export default {
data() {
return {
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false
}
},
computed: {
currentUserId() {
return this.$store.state.user.user.id
},
userStream() {
return this.$store.state.streamAudiobook
},
usersOnline() {
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
return usermap
}
},
methods: {
getLastRead(audiobooks) {
var abs = Object.values(audiobooks)
if (abs.length) {
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
})
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
}
},
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('user_removed', this.userRemoved)
}
}
}
</script>
<style>
#accounts {
table-layout: fixed;
border-collapse: collapse;
border: 1px solid #474747;
width: 100%;
}
#accounts td,
#accounts th {
/* border: 1px solid #2e2e2e; */
padding: 8px 8px;
text-align: left;
}
#accounts td.py-0 {
padding: 0px 8px;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
#accounts tr:hover {
background-color: #444;
}
#accounts th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>

View File

@@ -1,5 +1,14 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
@@ -13,6 +22,7 @@
<script>
export default {
props: {
to: String,
color: {
type: String,
default: 'primary'
@@ -46,6 +56,9 @@ export default {
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}
return list
}
},
@@ -59,7 +72,7 @@ export default {
</script>
<style>
button.btn::before {
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
@@ -70,7 +83,7 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover:not(:disabled)::before {
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {

View File

@@ -0,0 +1,42 @@
<template>
<label class="flex justify-start items-start">
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500" :class="wrapperClass">
<input v-model="selected" type="checkbox" class="opacity-0 absolute" />
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none">{{ label }}</div>
</label>
</template>
<script>
export default {
props: {
value: Boolean,
label: Boolean,
small: Boolean
},
data() {
return {}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', !!val)
}
},
wrapperClass() {
if (this.small) return 'w-4 h-4'
return 'w-6 h-6'
},
svgClass() {
if (this.small) return 'w-3 h-3'
return 'w-4 h-4'
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="relative w-full" v-click-outside="clickOutside">
<p class="text-sm font-semibold">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">expand_more</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: {
type: String,
default: ''
},
items: {
type: Array,
default: () => []
},
disabled: Boolean,
small: Boolean
},
data() {
return {
showMenu: false
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItem() {
return this.items.find((i) => i.value === this.selected)
},
selectedText() {
return this.selectedItem ? this.selectedItem.text : ''
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.selected = itemValue
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-1 bg-transparent border-b border-opacity-0 border-gray-400 focus:border-opacity-100 focus:outline-none" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
readonly: Boolean,
type: {
type: String,
default: 'text'
},
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) {
this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
background-color: #444;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
</div>
</template>
<script>
export default {
props: {
accept: {
type: String,
default: '.png, .jpg, .jpeg, .webp'
}
},
data() {
return {}
},
computed: {},
methods: {
reset() {
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
clickUpload() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
var file = _files[0]
console.log('File', file)
this.$emit('change', file)
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
<span class="material-icons" :style="{ fontSize }">{{ icon }}</span>
</button>
</template>
<script>
export default {
props: {
icon: String,
disabled: Boolean,
bgColor: {
type: String,
default: 'primary'
}
},
data() {
return {}
},
computed: {
className() {
var classes = []
classes.push(`bg-${this.bgColor}`)
return classes.join(' ')
},
fontSize() {
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}
},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn:disabled {
cursor: not-allowed;
}
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded px-2 py-2">
<input ref="input" v-model="textInput" :readonly="!editable" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
@@ -33,6 +33,7 @@
export default {
props: {
value: [String, Number],
disabled: Boolean,
label: String,
items: {
type: Array,
@@ -95,7 +96,7 @@ export default {
}
this.isFocused = false
if (this.input !== this.textInput) {
var val = this.$cleanString(this.textInput) || null
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
@@ -104,7 +105,7 @@ export default {
}, 50)
},
submitForm() {
var val = this.$cleanString(this.textInput) || null
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
@@ -112,13 +113,12 @@ export default {
this.currentSearch = null
},
clickedOption(e, item) {
var newValue = this.input === item ? null : item
this.textInput = null
this.currentSearch = null
this.input = this.$cleanString(newValue) || null
this.input = item
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
data() {
return {
showMenu: false,
disabled: false
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibrary() {
return this.libraries.find((lib) => lib.id === this.currentLibraryId)
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon || 'database' : 'database'
},
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
},
canUserAccessAllLibraries() {
return this.$store.getters['user/getUserCanAccessAllLibraries']
},
userLibrariesAccessible() {
return this.$store.getters['user/getLibrariesAccessible']
},
librariesFiltered() {
if (this.canUserAccessAllLibraries) return this.libraries
return this.libraries.filter((lib) => {
return this.userLibrariesAccessible.includes(lib.id)
})
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickOutside() {
this.showMenu = false
},
selectLibrary(library) {
this.updateLibrary(library)
this.showMenu = false
},
async updateLibrary(library) {
this.disabled = true
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.disabled = false
}
},
mounted() {}
}
</script>

View File

@@ -1,13 +1,13 @@
<template>
<div class="w-40">
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>

View File

@@ -8,7 +8,7 @@
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ $snakeToNormal(item) }}
{{ item }}
</div>
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
@@ -18,7 +18,7 @@
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ $snakeToNormal(item) }}</span>
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
@@ -75,8 +75,7 @@ export default {
}
return this.items.filter((i) => {
var normie = this.$snakeToNormal(i)
var iValue = String(normie).toLowerCase()
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
@@ -128,6 +127,7 @@ export default {
return
}
this.isFocused = false
if (this.textInput) this.submitForm()
}, 50)
},
focus() {
@@ -146,6 +146,7 @@ export default {
var newSelected = null
if (this.selected.includes(itemValue)) {
newSelected = this.selected.filter((s) => s !== itemValue)
this.$emit('removedItem', itemValue)
} else {
newSelected = this.selected.concat([itemValue])
}
@@ -165,14 +166,15 @@ export default {
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$emit('removedItem', item)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) {
var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem)
this.selected.push(item)
this.$emit('input', this.selected)
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.$nextTick(() => {
@@ -182,10 +184,9 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
var cleanedKebab = this.$normalToSnake(cleaned)
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i
return i === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)

View File

@@ -0,0 +1,120 @@
<template>
<div class="w-full" v-click-outside="closeMenu">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div ref="wrapper" class="relative">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selectedItems" :key="item.value" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.value)">close</span>
</div>
{{ item.text }}
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
items: {
type: Array,
default: () => []
},
label: String
},
data() {
return {
showMenu: false,
menu: null
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItems() {
return (this.value || []).map((v) => {
return this.items.find((i) => i.value === v) || {}
})
}
},
methods: {
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
var newSelected = null
if (this.selected.includes(item.value)) {
newSelected = this.selected.filter((s) => s !== item.value)
} else {
newSelected = this.selected.concat([item.value])
}
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
closeMenu() {
this.showMenu = false
},
clickWrapper() {
this.showMenu = !this.showMenu
},
removeItem(itemValue) {
var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" 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>
</button>
</template>
<script>
export default {
props: {
isRead: Boolean,
disabled: Boolean
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -1,5 +1,10 @@
<template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div 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" />
<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>
</div>
</div>
</template>
<script>
@@ -12,8 +17,18 @@ export default {
type: String,
default: 'text'
},
transparent: Boolean,
disabled: Boolean
disabled: Boolean,
paddingY: {
type: Number,
default: 2
},
paddingX: {
type: Number,
default: 3
},
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean
},
data() {
return {}
@@ -26,9 +41,20 @@ export default {
set(val) {
this.$emit('input', val)
}
},
classList() {
var _list = []
_list.push(`px-${this.paddingX}`)
_list.push(`py-${this.paddingY}`)
if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center')
return _list.join(' ')
}
},
methods: {
clear() {
this.inputValue = ''
},
focused() {
this.$emit('focus')
},
@@ -40,6 +66,9 @@ export default {
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
@@ -51,6 +80,7 @@ input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
background-color: #444;
}
</style>

View File

@@ -1,6 +1,8 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div>
</template>
@@ -10,6 +12,7 @@ export default {
props: {
value: [String, Number],
label: String,
note: String,
type: {
type: String,
default: 'text'

View File

@@ -38,10 +38,11 @@ export default {
</script>
<style scoped>
input {
textarea {
border-style: inherit !important;
}
input:read-only {
background-color: #eee;
textarea:read-only {
color: #aaa;
background-color: #444;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div>
</template>
@@ -10,6 +10,7 @@ export default {
props: {
value: [String, Number],
label: String,
disabled: Boolean,
rows: {
type: Number,
default: 2

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-end" :class="toggleColor" @click="clickToggle">
<span class="rounded-full border w-6 h-6 border-black-50 bg-white shadow transform transition-transform duration-100" :class="!toggleValue ? '-translate-x-6' : ''"> </span>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
@@ -9,7 +9,16 @@
<script>
export default {
props: {
value: Boolean
value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
},
computed: {
toggleValue: {
@@ -20,13 +29,18 @@ export default {
this.$emit('input', val)
}
},
toggleColor() {
return this.toggleValue ? 'bg-success' : 'bg-primary'
className() {
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {
clickToggle() {
console.log('click toggle', this.toggleValue)
if (this.disabled) return
this.toggleValue = !this.toggleValue
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
<div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot />
</div>
</template>
@@ -14,39 +14,94 @@ export default {
direction: {
type: String,
default: 'right'
}
},
disabled: Boolean
},
data() {
return {
tooltip: null,
tooltipId: null,
isShowing: false
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltip) {
this.tooltip.innerHTML = this.text
this.setTooltipPosition(this.tooltip)
}
},
getTextWidth() {
var styles = {
'font-size': '0.75rem'
}
var size = this.$calculateTextSize(this.text, styles)
return size.width
},
createTooltip() {
if (!this.$refs.box) return
var tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip)
this.tooltip = tooltip
},
setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect()
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove()
var top = 0
var left = 0
if (this.direction === 'right') {
top = boxChow.top
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left + boxChow.width + 4
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left
left = boxChow.left - width / 2 + boxChow.width / 2
} else if (this.direction === 'top') {
top = boxChow.top - height - 4
left = boxChow.left - width / 2 + boxChow.width / 2
} else if (this.direction === 'left') {
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left - width - 4
}
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
tooltip.style.zIndex = 100
tooltip.innerText = this.text
this.tooltip = tooltip
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
if (!this.tooltip) return
}
if (!this.$refs.box) return // Ensure element is not destroyed
try {
document.body.appendChild(this.tooltip)
this.setTooltipPosition(this.tooltip)
} catch (error) {
console.error(error)
}
document.body.appendChild(this.tooltip)
this.isShowing = true
},
hideTooltip() {

View File

@@ -0,0 +1,33 @@
<template>
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.Vue-Toastification__close-button.cancel-scan-btn {
background-color: rgb(255, 82, 82);
color: white;
font-size: 0.9rem;
opacity: 1;
padding: 0px 10px;
border-radius: 6px;
font-weight: normal;
font-family: 'Open Sans';
margin-left: 10px;
opacity: 0.3;
}
.Vue-Toastification__close-button.cancel-scan-btn:hover {
background-color: rgb(235, 65, 65);
opacity: 1;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="h-4 w-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
</template>
<script>
export default {
props: {
icon: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="w-3 h-3">
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
},
scanNum() {
return this.scanProgress ? this.scanProgress.done : 0
},
scanTotal() {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>

View File

@@ -1,14 +1,19 @@
<template>
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar />
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-edit-modal />
<widgets-scan-alert />
<readers-reader />
</div>
</template>
<script>
import CloseButton from '@/components/widgets/CloseButton'
export default {
middleware: 'authenticated',
data() {
@@ -24,6 +29,9 @@ export default {
if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', [])
}
if (this.$store.state.audiobooks.keywordFilter) {
this.$store.commit('audiobooks/setKeywordFilter', '')
}
}
},
computed: {
@@ -56,6 +64,23 @@ export default {
this.$store.commit('user/setUser', payload.user)
this.$store.commit('user/setSettings', payload.user.settings)
}
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
}
if (payload.librariesScanning) {
payload.librariesScanning.forEach((libraryScan) => {
this.scanStart(libraryScan)
})
}
if (payload.backups && payload.backups.length) {
this.$store.commit('setBackups', payload.backups)
}
if (payload.usersOnline) {
this.$store.commit('users/resetUsers')
payload.usersOnline.forEach((user) => {
this.$store.commit('users/updateUser', user)
})
}
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -81,42 +106,61 @@ export default {
audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) {
this.$router.replace('/')
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
}
}
this.$store.commit('audiobooks/remove', audiobook)
},
scanComplete({ scanType, results }) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', false)
if (results) {
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
}
} else {
this.$store.commit('setIsScanning', false)
if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
libraryAdded(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanStart(scanType) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanProgress({ scanType, progress }) {
if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
libraryRemoved(library) {
this.$store.commit('libraries/remove', library)
},
scanComplete(data) {
var message = `Scan "${data.name}" complete!`
if (data.results) {
var scanResultMsgs = []
var results = data.results
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) message += '\nEverything was up to date'
else message += '\n' + scanResultMsgs.join('\n')
} else {
this.$store.commit('setScanProgress', progress)
message = `Scan "${data.name}" was canceled`
}
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
} else {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
}
this.$store.commit('scanners/remove', data)
},
onScanToastCancel(id) {
this.$root.socket.emit('cancel_scan', id)
},
scanStart(data) {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
this.$store.commit('scanners/addUpdate', data)
},
scanProgress(data) {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} else {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
}
this.$store.commit('scanners/addUpdate', data)
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
@@ -124,41 +168,90 @@ export default {
this.$store.commit('user/setSettings', user.settings)
}
},
downloadStarted(download) {
var filename = download.filename
this.$toast.success(`Preparing download for "${filename}"`)
userOnline(user) {
this.$store.commit('users/updateUser', user)
},
userOffline(user) {
this.$store.commit('users/removeUser', user)
},
userStreamUpdate(user) {
this.$store.commit('users/updateUser', user)
},
currentUserAudiobookUpdate(payload) {
// console.log('Received user audiobook update', payload)
this.$store.commit('user/updateUserAudiobook', payload)
},
downloadToastClick(download) {
if (!download || !download.audiobookId) {
return console.error('Invalid download object', download)
}
download.isPending = true
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
if (!audiobook) {
return console.error('Audiobook not found for download', download)
}
this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
},
downloadStarted(download) {
download.status = this.$constants.DownloadStatus.PENDING
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadReady(download) {
var filename = download.filename
this.$toast.success(`Download "${filename}" is ready!`)
download.status = this.$constants.DownloadStatus.READY
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isPending = false
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
} else {
this.$toast.success(`Download "${download.filename}" is ready!`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadFailed(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" is failed`)
download.status = this.$constants.DownloadStatus.FAILED
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isFailed = true
download.isReady = false
download.isPending = false
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
} else {
console.warn('Download failed no existing download', existingDownload)
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadKilled(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" was terminated`)
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
} else {
console.warn('Download killed no existing download found', existingDownload)
this.$toast.error(`Download "${download.filename}" was terminated`)
}
this.$store.commit('downloads/removeDownload', download)
},
downloadExpired(download) {
download.isExpired = true
download.isReady = false
download.isPending = false
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
showErrorToast(message) {
this.$toast.error(message)
},
showSuccessToast(message) {
this.$toast.success(message)
},
logEvtReceived(payload) {
this.$store.commit('logs/logEvt', payload)
},
backupApplied() {
// Force refresh
location.reload()
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -192,8 +285,17 @@ export default {
this.socket.on('audiobook_added', this.audiobookAdded)
this.socket.on('audiobook_removed', this.audiobookRemoved)
// Library Listeners
this.socket.on('library_updated', this.libraryUpdated)
this.socket.on('library_added', this.libraryAdded)
this.socket.on('library_removed', this.libraryRemoved)
// User Listeners
this.socket.on('user_updated', this.userUpdated)
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('current_user_audiobook_update', this.currentUserAudiobookUpdate)
// Scan Listeners
this.socket.on('scan_start', this.scanStart)
@@ -206,15 +308,129 @@ export default {
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
// Toast Listeners
this.socket.on('show_error_toast', this.showErrorToast)
this.socket.on('show_success_toast', this.showSuccessToast)
this.socket.on('log', this.logEvtReceived)
this.socket.on('backup_applied', this.backupApplied)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
var latestVersion = versionData.latestVersion
if (!ignoreVersion || ignoreVersion !== latestVersion) {
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
position: 'top-center',
toastClassName: 'cursor-pointer',
bodyClassName: 'custom-class-1',
timeout: 20000,
closeOnClick: false,
draggable: false,
hideProgressBar: false,
onClick: () => {
window.open(versionData.githubTagUrl, '_blank')
},
onClose: () => {
localStorage.setItem('ignoreVersion', versionData.latestVersion)
}
})
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
},
checkActiveElementIsInput() {
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) {
// Unused hotkey
return null
}
var keyName = this.$keynames[keyCode]
var name = keyName
if (e.shiftKey) name = 'Shift-' + keyName
if (process.env.NODE_ENV !== 'production') {
console.log('Hotkey command', name)
}
return name
},
keyDown(e) {
var name = this.getHotkeyName(e)
if (!name) return
// Input is focused then ignore key press
if (this.checkActiveElementIsInput()) {
return
}
// Modal is open
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
this.$eventBus.$emit('modal-hotkey', name)
e.preventDefault()
return
}
// EReader is open
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
this.$eventBus.$emit('reader-hotkey', name)
e.preventDefault()
return
}
// Batch selecting
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
// ESCAPE key cancels batch selection
this.$store.commit('setSelectedAudiobooks', [])
e.preventDefault()
return
}
// Playing audiobook
if (this.$store.state.streamAudiobook && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
this.$eventBus.$emit('player-hotkey', name)
e.preventDefault()
}
}
},
mounted() {
window.addEventListener('keydown', this.keyDown)
this.initializeSocket()
this.$store.dispatch('libraries/load')
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
this.$store
.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() {
window.removeEventListener('keydown', this.keyDown)
}
}
</script>
</script>
<style>
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
</style>

View File

@@ -1,7 +1,7 @@
export default function ({ store, redirect, route }) {
export default function ({ store, redirect, route, app }) {
// If the user is not authenticated
if (!store.state.user.user) {
if (route.name === 'batch') return redirect('/login')
return redirect(`/login?redirect=${route.path}`)
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
return redirect(`/login?redirect=${route.fullPath}`)
}
}

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