mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
2805 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48f232790a | |||
| 3c55aa5f43 | |||
| 8c1edb30a6 | |||
| 5e64af4448 | |||
| 9f60017cfe | |||
| b6a86d11d2 | |||
| db86bfd63d | |||
| 7ff72a8920 | |||
| 2c4f86d148 | |||
| 1a9f26e804 | |||
| 42f8194bde | |||
| 8634b7058c | |||
| fc276b330a | |||
| 5b22d7430a | |||
| 8883debc74 | |||
| c92cb08f6f | |||
| 1254b668de | |||
| 48b703bf9f | |||
| 064679c057 | |||
| ba23d258e7 | |||
| 98cd19d440 | |||
| 4c8b91e9d9 | |||
| ba742563c2 | |||
| f0e70ed27b | |||
| acc4bdbc5e | |||
| c45c82306e | |||
| fd827b2214 | |||
| df1c157994 | |||
| a92e417581 | |||
| 6ad0719880 | |||
| 5383d0b5f7 | |||
| b3cefc075d | |||
| ac62d18007 | |||
| fe14c26782 | |||
| b33a3cabf9 | |||
| 6224163ecd | |||
| 05aabb2843 | |||
| 7d2d5f6bf4 | |||
| c938685679 | |||
| e6ecc28001 | |||
| 93fa6ba466 | |||
| a8f459e4fa | |||
| 2441bb1cec | |||
| 25cc24fca5 | |||
| ff4cbc6d5f | |||
| f79bfae95d | |||
| 2f99efcc60 | |||
| 45b13571a5 | |||
| 04da8812df | |||
| 840304ee04 | |||
| 41bd9a9358 | |||
| 1e0a9918fd | |||
| 799acf5db8 | |||
| 1326d29fad | |||
| 9b35530956 | |||
| 0ae054c5d7 | |||
| c72eac9987 | |||
| 159ccd807f | |||
| 5d13faef33 | |||
| e0de59a4b6 | |||
| 519a1b0eaf | |||
| 4d8e1b7cef | |||
| 6d3e096e08 | |||
| 38edcdca4b | |||
| 8774e6be71 | |||
| ec197b2e13 | |||
| 1c0d6e9c67 | |||
| 7d711da381 | |||
| f66cea9829 | |||
| 5f572face5 | |||
| 88a4cf9f12 | |||
| 0b860e0d40 | |||
| 149bb3e5b2 | |||
| 7a7a779824 | |||
| 20a3657063 | |||
| 9c87c3a095 | |||
| 4de65b4369 | |||
| 996c78d760 | |||
| ccdc3d60c4 | |||
| 8be08882d8 | |||
| 26d2c5a8f0 | |||
| bae39e3a2d | |||
| bb1a72269a | |||
| 9674cfd258 | |||
| 627ddd2f70 | |||
| 27b3a44147 | |||
| 5308fd8b46 | |||
| 1b914d5d4f | |||
| 9e0f17f7c6 | |||
| 1320b6d785 | |||
| f1ddbeadaf | |||
| f9f89e1e51 | |||
| bbf214fa4c | |||
| f1582177e1 | |||
| d5712a564c | |||
| 1c274862d8 | |||
| 663c9e0fa9 | |||
| bcb0bc75c9 | |||
| 603823d6ea | |||
| 20c04d3ed3 | |||
| 02e5d608d0 | |||
| e53ac6566b | |||
| 2472b86284 | |||
| 29a15858f4 | |||
| afc16358ca | |||
| 9facf77ff1 | |||
| 1923854202 | |||
| 9cd92c7b7f | |||
| 8e0b723207 | |||
| 68ef3a07a7 | |||
| 202ceb02b5 | |||
| 59370cae81 | |||
| 52a3bc224a | |||
| 54d67e5216 | |||
| b55d8250cc | |||
| 3a1e9abd68 | |||
| c5ba40a178 | |||
| f0c6dccadb | |||
| e701d1ab6a | |||
| e10c8093c9 | |||
| e81b3461b2 | |||
| 9345cb3934 | |||
| eb36a0b3dd | |||
| 7e442ecb3d | |||
| f07c5eb725 | |||
| a486be92cb | |||
| 4d84060036 | |||
| fc503691fe | |||
| c80dd43a3e | |||
| a4a62e0c18 | |||
| 2f98cb9b6d | |||
| 91dc6eebb0 | |||
| d72e0a4418 | |||
| 2c8ebd43cc | |||
| 9f561aa296 | |||
| 930bacd45d | |||
| ef2d736b20 | |||
| f3a453be20 | |||
| 45c97a778d | |||
| 6ebc64f73b | |||
| 52807d0d49 | |||
| a5e18e99bc | |||
| f545b3e745 | |||
| e0877803e3 | |||
| 4916887c8d | |||
| 20eb573897 | |||
| 8ff7b6b6e6 | |||
| 06eaee8909 | |||
| 8f9487ba70 | |||
| eca51457b7 | |||
| 15c6fce648 | |||
| 6c872263c6 | |||
| 4d3b3d1740 | |||
| bba8920855 | |||
| f56b9487ff | |||
| 1946d8296b | |||
| 41e5d7f820 | |||
| 2507568103 | |||
| 19733798fa | |||
| 427d6da360 | |||
| 2b67d3d1c5 | |||
| 6926a40ad6 | |||
| 7a8da5bf3a | |||
| fc8fa17c6f | |||
| 0a88659a9f | |||
| 9967858c44 | |||
| e2ce388f90 | |||
| f31649f1d2 | |||
| a55c167dde | |||
| 642cf232ba | |||
| 164b4525c4 | |||
| 39c26d2bee | |||
| 2a69955cc1 | |||
| 4a5345dd5d | |||
| 1e6dd0e3e0 | |||
| 91cca2e358 | |||
| 816a9be618 | |||
| 9eb0ec76fe | |||
| 49054d5239 | |||
| 787c4e45a8 | |||
| 34cb7a4d02 | |||
| 006241163b | |||
| 03818fadee | |||
| 897c3ea625 | |||
| 73e4293f04 | |||
| 6f5ffcb1f8 | |||
| ed70f3af83 | |||
| 73196f9be8 | |||
| a77f4e9d77 | |||
| 294490f814 | |||
| 6183001fca | |||
| 3ac604c665 | |||
| e342b07cd0 | |||
| b524cbd1b3 | |||
| 88693d73bd | |||
| 2c453a34ee | |||
| 3d2b2e43b1 | |||
| c3f3fca896 | |||
| dedf6e5d4b | |||
| 6c379fc3a7 | |||
| 329e9c9eb2 | |||
| ee53086444 | |||
| 43d6c6678f | |||
| 82f136ba79 | |||
| e40d3dd64d | |||
| a5897fd64b | |||
| e786e3c057 | |||
| d347645475 | |||
| 215b78c162 | |||
| ee271519f9 | |||
| b350277bbc | |||
| 604ae080ac | |||
| a191dab359 | |||
| 3223011b13 | |||
| f746e246e4 | |||
| 0476b68585 | |||
| ec395bed72 | |||
| bff56220c2 | |||
| 3006405a52 | |||
| 9cd0ac80b1 | |||
| da51d38ba2 | |||
| 5ba6459069 | |||
| 75899242fd | |||
| 7faf42d892 | |||
| 10f5f331d7 | |||
| b1414388e1 | |||
| eb0f5b2e1b | |||
| 7af02ad2e2 | |||
| 8330dabc46 | |||
| dbc7ad0b3b | |||
| c0fd24770e | |||
| 4289fe4990 | |||
| e925e9b23f | |||
| 71cd86fdd5 | |||
| 03be947ad6 | |||
| 96f9084f2e | |||
| bbccfcbd12 | |||
| 9a697f48db | |||
| 37ad1cced2 | |||
| 26db20f63d | |||
| ff788e3591 | |||
| 4b482488de | |||
| e230b6640f | |||
| 2bc949fae3 | |||
| b1bc472205 | |||
| 5c7a38c292 | |||
| bbd6c51eb6 | |||
| d17f9b0687 | |||
| 4d2bdb6eee | |||
| b6a1014c72 | |||
| b99885c806 | |||
| f422c9b820 | |||
| 0befe91360 | |||
| da671e3fd5 | |||
| fec94c18aa | |||
| 11c6fc7d90 | |||
| 7ea5e7dc95 | |||
| 2a98e2c361 | |||
| 7fb499b301 | |||
| af9aee76cf | |||
| 075ec15f02 | |||
| 1c650473f8 | |||
| 0efdf50821 | |||
| df65ef2191 | |||
| bc3b1d9565 | |||
| 2998d3ba6a | |||
| ea11153032 | |||
| 733f61075f | |||
| 618e69775c | |||
| eabfa90121 | |||
| 43b7ccd61a | |||
| b6875a44cf | |||
| c0004dd532 | |||
| 0ee3b89760 | |||
| c5e60d30e1 | |||
| acaf1ac196 | |||
| 8dc4538c95 | |||
| e224fd2595 | |||
| f0a1ea4d6d | |||
| 10cb8ebf3b | |||
| 8c4afa1866 | |||
| eb5af47bbf | |||
| 4fd93ce64c | |||
| 7ba4e9e66d | |||
| e2e5449d25 | |||
| abc76ca155 | |||
| 0fc84a8684 | |||
| a76600e53b | |||
| e55cf30705 | |||
| 2c65b8fd2b | |||
| 20b8e35132 | |||
| 8007225a41 | |||
| 63a6da6680 | |||
| 93114b2181 | |||
| f6dd3de8e7 | |||
| 0918391636 | |||
| 972b4f7388 | |||
| af92ae4d51 | |||
| 3bc6426cc7 | |||
| acfbbd5aec | |||
| 9b677be12e | |||
| 2f2ec2ec1f | |||
| e05ab14ad2 | |||
| 9074e9ed88 | |||
| 1e5cb09ada | |||
| b0f1827e3c | |||
| ae7713bacc | |||
| b6c185eebe | |||
| 5114be0773 | |||
| 9a4c5a16ef | |||
| e6b1acfb44 | |||
| 1e5787c60d | |||
| 928b080677 | |||
| 3764ef14a9 | |||
| 92aae736c4 | |||
| 31c8cb476a | |||
| 3a2f786517 | |||
| 7c0b4e35d7 | |||
| 0461b57e6c | |||
| a1688488e5 | |||
| 4d24817ced | |||
| d46de541d6 | |||
| 37f62d22b6 | |||
| 79bd6a25d9 | |||
| 0042604e6d | |||
| b01ef1c691 | |||
| 277ff8a5a5 | |||
| d5f991ae4a | |||
| 54f2bb1092 | |||
| 6b6df619f5 | |||
| fed5ff4863 | |||
| 43217657d7 | |||
| fa1518cb1d | |||
| 6d14ed8a72 | |||
| b8e17de8b4 | |||
| e60a91379a | |||
| 046bf52d88 | |||
| bfc3c7e7c9 | |||
| dd1d2b7c92 | |||
| 8bdee51798 | |||
| 5858b64fc6 | |||
| 4baa89c8e1 | |||
| 1b015beba4 | |||
| ebaec23648 | |||
| d5e00c8bbd | |||
| 4732ca8119 | |||
| 134c2580c9 | |||
| 8e286a6070 | |||
| d7ace4d1dc | |||
| a21b1f3b16 | |||
| c309856f74 | |||
| 31146082f0 | |||
| 6fbbc65edf | |||
| c1349e586a | |||
| 8985ebebe2 | |||
| 394a004ff5 | |||
| 33e6ad4ad6 | |||
| 05a0793a9c | |||
| 3a5e9cd865 | |||
| a7cd79850d | |||
| 386edb0427 | |||
| 6c1e25e964 | |||
| a6a956fc28 | |||
| fb7d6807e2 | |||
| e9f8ca1c14 | |||
| c669ca5be1 | |||
| 6dd0fb4225 | |||
| 709f9a65fa | |||
| 3c888d2876 | |||
| aca39011bb | |||
| f6fc53d7d8 | |||
| 599623570b | |||
| 67b47785a0 | |||
| 56c0124c13 | |||
| f9e270e4be | |||
| 8cadaa57f6 | |||
| 042035051d | |||
| 12ce3a6147 | |||
| 9bf4bd9bfa | |||
| 2819317924 | |||
| e06ab594e1 | |||
| 04a65648a3 | |||
| 2673742d8d | |||
| 090c02079d | |||
| 514fb5f7da | |||
| f541bc2159 | |||
| d70810364c | |||
| 09d7880779 | |||
| c69e6bff10 | |||
| b49c2e7b82 | |||
| d012b2107d | |||
| 9294521632 | |||
| 7d05317357 | |||
| 2843a3b6d7 | |||
| 635f22ddfe | |||
| 903b685e1a | |||
| 09bcc1191f | |||
| d6eae9b43e | |||
| f95d9bd0e9 | |||
| e52b695f7e | |||
| 72c1407aa7 | |||
| 2ec49cbdb1 | |||
| 331d7a41ab | |||
| 8498cab842 | |||
| c170cb3132 | |||
| 0c58c9060e | |||
| e3c3903c71 | |||
| 7bc70effb0 | |||
| 991da2870f | |||
| 52b632d810 | |||
| 33531ff73b | |||
| 391a777dde | |||
| 85e7b63532 | |||
| b02429cf55 | |||
| 9e064e670a | |||
| 61b3785038 | |||
| a75ad5d659 | |||
| 516a3858c5 | |||
| 364787db72 | |||
| b2562ede55 | |||
| c441d83d39 | |||
| 08c6cc674b | |||
| 9c34e4bd14 | |||
| 9b159fc1e6 | |||
| bcc2fa409e | |||
| 360d54847c | |||
| b25314b4bd | |||
| c87f2a571e | |||
| 8be02303f9 | |||
| c6b4694b22 | |||
| 4762cdb7d8 | |||
| fe2a07bf4b | |||
| 9f80900717 | |||
| 6b001ad7a1 | |||
| 4241544aaf | |||
| 80bcc71c72 | |||
| 253095dcd6 | |||
| 0e4109a7c2 | |||
| 629741db92 | |||
| 79236dd67d | |||
| bdfb7b9af3 | |||
| 665244f1b2 | |||
| b74f13bbd7 | |||
| d1ee3af2d9 | |||
| 38fa4d4169 | |||
| 56d3ed5a8e | |||
| cadef9b023 | |||
| 34b340f179 | |||
| b89bbd2187 | |||
| d6438590d7 | |||
| baf5f7fbc3 | |||
| e6a2555f05 | |||
| 36425e1fab | |||
| 18efd95759 | |||
| f682a7a283 | |||
| cb968ef4ca | |||
| a6c5732693 | |||
| 7bbdc945d5 | |||
| b37431dfaa | |||
| a333ebe5b0 | |||
| 4affcd0d89 | |||
| 9d5e6351a4 | |||
| 91c25918f1 | |||
| bb88b5d861 | |||
| 11818a3576 | |||
| f3de134980 | |||
| 9fa5db6976 | |||
| 5e9043e5fa | |||
| 84e275174c | |||
| ae90dd358e | |||
| 0cfd153694 | |||
| bf99d3d506 | |||
| 9e055831fe | |||
| a349784da9 | |||
| 40f9e0f669 | |||
| c253a95127 | |||
| d70d49b9da | |||
| 16c5e4a398 | |||
| d53d16c551 | |||
| 312be0f639 | |||
| 0246dcc10d | |||
| 5aa1b14695 | |||
| 2ee24c1ded | |||
| 700afeacf0 | |||
| e9453d4f6c | |||
| 661db2af26 | |||
| efd205716b | |||
| 84144bb32a | |||
| 74a094c6df | |||
| aa89aca632 | |||
| 8ac9a0d7c0 | |||
| 0119d7fcff | |||
| be513fde4f | |||
| 715199d88b | |||
| 34942a3857 | |||
| d67e916c66 | |||
| e3e2d4ff99 | |||
| 699615f2f3 | |||
| 6d267cac0d | |||
| 7d719d94ba | |||
| 4bf410fd3e | |||
| 16cd05e187 | |||
| c7dcaa0316 | |||
| 09cf502e70 | |||
| 78ac7c2a28 | |||
| 57acda5592 | |||
| d52a168582 | |||
| 97a9782f31 | |||
| 11d8669426 | |||
| 2bceb6654a | |||
| 6feea6a1b0 | |||
| 139919ab20 | |||
| 234234cc5c | |||
| fcd74ae17b | |||
| c2897f819d | |||
| a018374d26 | |||
| ee501f70ed | |||
| e9e9a8ba75 | |||
| 5da4861716 | |||
| 9c7569fa7a | |||
| c8892c3725 | |||
| ef05e37a04 | |||
| 065aae9a7e | |||
| 06202811b4 | |||
| 3ef189ed4a | |||
| 5f8066e601 | |||
| ace490712e | |||
| 265cd75691 | |||
| f43969e429 | |||
| 9adfdda7da | |||
| 0715de8147 | |||
| 9c33446449 | |||
| 651601adf6 | |||
| 2186603039 | |||
| 2b5c7fb519 | |||
| 82dcd2d6fb | |||
| 3f2925029c | |||
| 4da4cf2885 | |||
| ae412f2a57 | |||
| 95506bc638 | |||
| 4b7b10a901 | |||
| 800cdc129d | |||
| fb86b4fc84 | |||
| 941f3248d8 | |||
| 6edbab863a | |||
| a9a317a378 | |||
| 3fd290c518 | |||
| b0924e4ce8 | |||
| 24adc8f66f | |||
| 964ef910b6 | |||
| ba6a88a5bf | |||
| 1576164218 | |||
| 94400f7794 | |||
| 41e1b02f3a | |||
| 1337c60cde | |||
| e9b4e07bd8 | |||
| 607fdffc18 | |||
| 216139119b | |||
| 19cbd1f8de | |||
| bf893a56c9 | |||
| 3a2f680a51 | |||
| ce7f891b9b | |||
| 8ec9da143f | |||
| 7f28fbb330 | |||
| 3111d1860a | |||
| bd3dce26d9 | |||
| db9ee301e3 | |||
| 7d8fb3bb10 | |||
| 6fa49e0aab | |||
| 30d3e41542 | |||
| c58d613949 | |||
| 38ba7fbec2 | |||
| 6fad4521d4 | |||
| 2f72300636 | |||
| b9cb54db71 | |||
| aaaa314761 | |||
| 4e40dbc3a5 | |||
| ba6a4f1224 | |||
| 524ed9b677 | |||
| 5bbcb9cac3 | |||
| ff169f3fd0 | |||
| cf7b08c993 | |||
| d99a77837b | |||
| 23dcf684d9 | |||
| 9c2ed279df | |||
| 700d7fe68e | |||
| 69833db819 | |||
| ab2026ecea | |||
| 811fd9018a | |||
| 6d89721371 | |||
| ab3a137db9 | |||
| a11cf7a90e | |||
| c995816076 | |||
| 94e7fc6434 | |||
| 3916bfe833 | |||
| 3080ada35f | |||
| 4cddc597c1 | |||
| ec07bfa940 | |||
| d20d4bf8c1 | |||
| 09e26a9e56 | |||
| ef74919f12 | |||
| 6462a50713 | |||
| 8c6c43657c | |||
| b8ed56e91e | |||
| dc0eaa32c9 | |||
| 60fc4e20e6 | |||
| 6f43b32214 | |||
| 5e8ae79d71 | |||
| 34718aa95d | |||
| d731ad1bd7 | |||
| e7fa698645 | |||
| 851d298916 | |||
| 1a27e2bef7 | |||
| d64860001b | |||
| b82ac3d536 | |||
| 91be9eb0fc | |||
| d61bb0bea0 | |||
| 911d72971e | |||
| b244cc8d41 | |||
| 8cc3bfa95e | |||
| ba3d59c645 | |||
| e416958b01 | |||
| 05c1ced65c | |||
| 057bc1a0c0 | |||
| 32fc224600 | |||
| fcecd415c8 | |||
| e384527b67 | |||
| 672672dd2a | |||
| fd22a6f51d | |||
| c674042319 | |||
| a668921e29 | |||
| 04ed4810fd | |||
| 941c798d78 | |||
| 7f12c71eca | |||
| f62d10746d | |||
| 13afa12456 | |||
| 4e1406f612 | |||
| ce98bcc989 | |||
| ff5cbae059 | |||
| 04a7f24bac | |||
| 68bfcb2e6e | |||
| 4bd7e21a51 | |||
| 37932f664a | |||
| 0081525ed3 | |||
| 7e13cb6ecf | |||
| 721dd14c1f | |||
| 047c8ec017 | |||
| fa5d2b2020 | |||
| dfe6505af0 | |||
| b0e33970b8 | |||
| d9f828c717 | |||
| 15ca3307bd | |||
| fa3b7e2f60 | |||
| a6de76a983 | |||
| 724e06e9d2 | |||
| bf3db1dae0 | |||
| 410801347c | |||
| 5041f80cb0 | |||
| 7229cfce84 | |||
| cb1ebd4a17 | |||
| 7929f3dc42 | |||
| 95cdb23efb | |||
| 182527bfa8 | |||
| 2eb19d46d5 | |||
| 10e7f142ec | |||
| c55988102d | |||
| d488b17869 | |||
| ff27c0b58b | |||
| 2bd532eb9a | |||
| e5fe31fe26 | |||
| ec83eb0a27 | |||
| 6236f53b4f | |||
| 1b2cf50633 | |||
| 3ab638ed61 | |||
| bd1309b680 | |||
| 00bc50c02d | |||
| e8bb92826a | |||
| a0cc42b385 | |||
| 7edc7ce861 | |||
| 0302ed986e | |||
| babfb6978a | |||
| 2cb53fafd7 | |||
| 8dbe35e5aa | |||
| bd06b6c716 | |||
| 8b27c726d5 | |||
| 68418c1d3b | |||
| a8af6db3d6 | |||
| af856ce1ec | |||
| aae8e7535a | |||
| 359a2752d8 | |||
| 9102a0045f | |||
| b124d61826 | |||
| 8e6ead59ce | |||
| f74d741821 | |||
| 0498d8cb83 | |||
| 15f83986e7 | |||
| a57fe42dff | |||
| b03198abd9 | |||
| ad30977781 | |||
| 129da51f76 | |||
| dbe10382fd | |||
| e5bababeae | |||
| 9b332f0e66 | |||
| a49c5afa46 | |||
| f0caf1a933 | |||
| 9e1c907591 | |||
| d638a328d8 | |||
| f597798839 | |||
| 303ef6b7c5 | |||
| 0f7c99d989 | |||
| 60c65008dc | |||
| c4fd4ff9de | |||
| 29fc503503 | |||
| bca49616e1 | |||
| cb49c17fc5 | |||
| 9e1686232b | |||
| f702358bbd | |||
| 9a0b8de354 | |||
| 6ed6fff6bd | |||
| 75007bb371 | |||
| df9da095ef | |||
| 64c98722c3 | |||
| 36c1a8b2df | |||
| 710d6af4b3 | |||
| cd7ecb9933 | |||
| f75f0b8cc8 | |||
| e60d2a9858 | |||
| 04993dd63d | |||
| 41af913280 | |||
| 8dc0f2c67c | |||
| fc196180b3 | |||
| 4a127d35b9 | |||
| 1525fdf4f6 | |||
| 8a29c998da | |||
| f56d9f128f | |||
| c5785e9c20 | |||
| 0ca91ecfff | |||
| 304d0f6d43 | |||
| 6c9a811472 | |||
| 116a7fb994 | |||
| 8e46181ba0 | |||
| a336686e42 | |||
| c8957fe373 | |||
| ca7eaf9750 | |||
| 74dd24febf | |||
| 7b856474af | |||
| c7ac12a67a | |||
| 3264359771 | |||
| c7cc994532 | |||
| afe40be957 | |||
| a9c9c447f1 | |||
| aa1aeacc09 | |||
| fc595bd799 | |||
| a5d7a81519 | |||
| 7e8fd91fc5 | |||
| c2ed0b7d3d | |||
| aefda8bd51 | |||
| 93bec282d2 | |||
| 1396a432a4 | |||
| 90e1283058 | |||
| 8cd50d5684 | |||
| 50bd2648aa | |||
| 33254654d5 | |||
| 617b8f4487 | |||
| f9b95bb003 | |||
| 740640884f | |||
| 86fea5c667 | |||
| 33e4b51aee | |||
| 1cf0bd0f01 | |||
| 8ce5a5cdbd | |||
| fc26b7af0a | |||
| 2d68fa2c27 | |||
| f241cb2280 | |||
| 125346bb5c | |||
| b60f62cebf | |||
| 51ff62356d | |||
| f827aa97f8 | |||
| 68276fe30b | |||
| 961533765f | |||
| c1bbec22f0 | |||
| 7d0eb215d6 | |||
| ff5226fa93 | |||
| 8d7530254c | |||
| 6957b4baf6 | |||
| 01c8d42291 | |||
| 1e21847852 | |||
| 1bee082720 | |||
| b0a9bed15a | |||
| 1d7434cbbb | |||
| 1646f0ebc2 | |||
| 50330b0a60 | |||
| f661e0835c | |||
| 9511122bae | |||
| 56f1bfef50 | |||
| 8e5b7504ae | |||
| 0a0006f949 | |||
| 5b836dfa28 | |||
| 8396900178 | |||
| 8f80948211 | |||
| 4ad09ec3d8 | |||
| be4eb28b21 | |||
| f938fca2c7 | |||
| d562f6a69f | |||
| 166454ef43 | |||
| d5c854d606 | |||
| eace46bf55 | |||
| b9ffce166e | |||
| 9713e94aed | |||
| d71bc89c9d | |||
| a2b2a2d060 | |||
| 752268effb | |||
| 9e3b3f3e12 | |||
| 88f9533b37 | |||
| 630ece82ad | |||
| 5777184cae | |||
| a76da14fb0 | |||
| 0c612b4836 | |||
| a1af672c7c | |||
| 5fcd23409a | |||
| 99f0799a11 | |||
| 316aeba1b0 | |||
| bfd4a378f3 | |||
| 521db90ae0 | |||
| d02fc2debe | |||
| e6c21c5be1 | |||
| 91248b496e | |||
| f7ae7783bd | |||
| ae395497a5 | |||
| 8826d3af62 | |||
| 65153fae9d | |||
| d4c1bc5dfc | |||
| d6f13513ae | |||
| 2584c3b432 | |||
| b54421412d | |||
| e2451a3281 | |||
| dbf4bd5c3d | |||
| 2a722ab163 | |||
| c83399c7b5 | |||
| a814e45150 | |||
| 29e9216bb1 | |||
| 94d1732b0d | |||
| 7610084627 | |||
| d840905a97 | |||
| 7b1b448795 | |||
| 77559d29bb | |||
| c14f9accaf | |||
| 76a1f48c62 | |||
| ae0a9bcf86 | |||
| 9e44fe5524 | |||
| 727dad7e19 | |||
| 0c2de91097 | |||
| 450fa45360 | |||
| e0dddae2c2 | |||
| daa9fccc14 | |||
| ad45dadc15 | |||
| 0e8148001e | |||
| fa71f9db2e | |||
| 0d9d2fa4be | |||
| c34e9cde05 | |||
| b934a755b5 | |||
| a5772f6b66 | |||
| 153f149d58 | |||
| e50b06183e | |||
| 305689d513 | |||
| 4dd140585d | |||
| cd60d0219f | |||
| 8ec18e8d7b | |||
| 15545654ea | |||
| 8a0fab2b20 | |||
| 6e8c6aa740 | |||
| 5005aabe5e | |||
| abc2d28617 | |||
| 7569a14510 | |||
| b52341dbcf | |||
| b4eed3bad2 | |||
| 4fe672f09d | |||
| 49af7eb7b0 | |||
| c93c863d82 | |||
| 763bb1b829 | |||
| 79d32274aa | |||
| 987842ed04 | |||
| d2b006b909 | |||
| f4a19e48ad | |||
| 38f12f4795 | |||
| 7a4f4b1586 | |||
| 20ec54e085 | |||
| 655bebfec4 | |||
| 71e1abd263 | |||
| 72172dcb33 | |||
| def2988e12 | |||
| b47793c365 | |||
| 3a99cc56b7 | |||
| 24c35dede5 | |||
| 8c4400dff1 | |||
| af8dffaa33 | |||
| 4a36a3c8e6 | |||
| e6735e042e | |||
| c799379a54 | |||
| d8b9f08e5a | |||
| 608b25de45 | |||
| 2db8869908 | |||
| 9500737bbe | |||
| def2b6425b | |||
| 5e8f247e84 | |||
| 761a2ff0bf | |||
| e368ffe29f | |||
| 0f4b11494e | |||
| 46448ce1e9 | |||
| fbe12b393f | |||
| ccf59b2c1a | |||
| d7af3b7788 | |||
| 682aca0b2a | |||
| 3328ffe1b9 | |||
| c07b7840e2 | |||
| 9f848b2c64 | |||
| 3d66ec0761 | |||
| f50920be69 | |||
| d31add9d5a | |||
| a4dcb4f92e | |||
| 2c589c1dbd | |||
| 60ea386c6d | |||
| 24be1a0ec5 | |||
| e71a14756b | |||
| 85fecbd1b9 | |||
| 335d39f317 | |||
| 973a18d346 | |||
| a43b93d796 | |||
| acf75abdf1 | |||
| 58598bfcf2 | |||
| 7a570439db | |||
| 6e769d1c20 | |||
| d9e7f5d133 | |||
| a119b05d85 | |||
| 7bf7b6bcf9 | |||
| e47ea98cdd | |||
| bf66e13377 | |||
| d7aba5629e | |||
| a5c200ac79 | |||
| fdc1fc1b2a | |||
| 42a4b762bd | |||
| 180c328ed1 | |||
| 2ec52a7a45 | |||
| aacf37e32b | |||
| 52323b7eb5 | |||
| 5b5613a762 | |||
| de6df0c029 | |||
| e180b3c171 | |||
| 1364b79cbf | |||
| ef96f3102f | |||
| 06ce3b08f7 | |||
| a13217dddf | |||
| ce528d4012 | |||
| 89207b6d2a | |||
| e9591caf81 | |||
| 24f1aae6b6 | |||
| 04fbc9a22b | |||
| 14e31d5690 | |||
| a9e9808183 | |||
| af7cb2432b | |||
| e0c1364916 | |||
| 04d16fc535 | |||
| 44135b3fed | |||
| 6111e8f0da | |||
| 4e3e7b10ce | |||
| ce7f81d676 | |||
| 0cf2f8885e | |||
| ddf4b2646c | |||
| fe1e0749a2 | |||
| 2093468c92 | |||
| 19af7454f2 | |||
| d24427aad8 | |||
| e2bb0cfb7c | |||
| 2ebdb44826 | |||
| 432e25565e | |||
| ebe511404a | |||
| e0a79fb86c | |||
| 295ca3d9a2 | |||
| dbad8bdb96 | |||
| 8c703859a0 | |||
| bedb260b00 | |||
| b49592301f | |||
| c6c67078b8 | |||
| 9e45ad10f1 | |||
| 24da859975 | |||
| 0b6a8a9641 | |||
| e43c4f082e | |||
| 0b334cf957 | |||
| ae387ab397 | |||
| 056e62dce8 | |||
| 47999214bd | |||
| 68473ee345 | |||
| 455f27d443 | |||
| ba996c3b55 | |||
| d43a1109c8 | |||
| c3ba7daa16 | |||
| 82048cd4f3 | |||
| 71b0a5cc81 | |||
| edb5ff1e33 | |||
| d4ed6348ee | |||
| f12ac685e8 | |||
| b9ec4068ee | |||
| 02aabb8f97 | |||
| dcec2154c0 | |||
| bbc1d20396 | |||
| e682213681 | |||
| 0153c0faae | |||
| 87ebf4722b | |||
| 3906dca04e | |||
| 399ba314a3 | |||
| 70827727aa | |||
| 73c21242b4 | |||
| 19e1803633 | |||
| 06391b9b37 | |||
| 71048c7ff0 | |||
| 7f350279fa | |||
| 4c9b2ad08b | |||
| 90f4833c9e | |||
| c0cb3a176f | |||
| 7b0fa48e2e | |||
| b51853b3df | |||
| f5545cd3f4 | |||
| e76af3bfc2 | |||
| 79c34d0638 | |||
| 6ef4944d89 | |||
| 850397e4c1 | |||
| 3b531144cf | |||
| 6ca684603c | |||
| cf85d66b2f | |||
| e8fa029df7 | |||
| 1a361c91f1 | |||
| 4a76059608 | |||
| da25eff5c1 | |||
| 69e23ef9f2 | |||
| 48a08e9659 | |||
| 4608f91ec6 | |||
| e88c1fa329 | |||
| 935e545caa | |||
| a426da534c | |||
| eaf6bf29cc | |||
| a0eb6bd3dc | |||
| fbe228a4f8 | |||
| 578a59063f | |||
| 81020ff34d | |||
| fea78898a5 | |||
| ffa7cc0d22 | |||
| 4f9969cd9b | |||
| 1be34564f2 | |||
| 56eff7a236 | |||
| 9f909b0d85 | |||
| baa65b8155 | |||
| 12c6a1baa0 | |||
| 5ea423072b | |||
| 08a41e37b4 | |||
| 8027c4a06f | |||
| a1e321b153 | |||
| 8c6a2ac5dd | |||
| b489bf9236 | |||
| aa63aa6cf3 | |||
| 9a2b93fb37 | |||
| e8ea7efc98 | |||
| 81a76593da | |||
| 5336864f7d | |||
| d38058e1d2 | |||
| fececd4651 | |||
| 021adf3104 | |||
| 160c83df4a | |||
| 456bb87a00 | |||
| 707451309c | |||
| 269676e8a5 | |||
| e4effebc19 | |||
| fbbceddba8 | |||
| 9a634e0de5 | |||
| 21d0d43edc | |||
| 3051b963ef | |||
| 0d0bdce337 | |||
| bdb5dc8c28 | |||
| 209847d98a | |||
| 14f42e15d1 | |||
| 7402e4811d | |||
| 6de0465b86 | |||
| cd7c4baaaf | |||
| a2db81bf7d | |||
| b376f89ce5 | |||
| 5633113f25 | |||
| 669415cfbf | |||
| 9f366863a9 | |||
| 0d644fe0c9 | |||
| 72fa6b8200 | |||
| 6d3f1d263a | |||
| 47bf9f7836 | |||
| 2738402aac | |||
| 68d36522b1 | |||
| 24a587b944 | |||
| 76119445a3 | |||
| 46ec59c74e | |||
| 2b7122c744 | |||
| 52f0a5432b | |||
| 7391b4d0ec | |||
| aa7ee3e8ff | |||
| bef0f3709f | |||
| f33b011847 | |||
| 2d8d11d4da | |||
| 10b1784f6d | |||
| f2f2ea161c | |||
| dc67a52000 | |||
| 05820aa820 | |||
| 8966dbbcd1 | |||
| cf32819c01 | |||
| 728496010c | |||
| 0a08f47942 | |||
| 39ceb02500 | |||
| 4336714248 | |||
| 1d41904fc3 | |||
| fae383a045 | |||
| 8f7a420cca | |||
| 9720ba3eed | |||
| d3256d59d5 | |||
| fa5f7ab7a5 | |||
| 6f26fd7238 | |||
| 6abc0819d9 | |||
| b580a23e7e | |||
| f659c3f11c | |||
| 0282a0521b | |||
| 75637e4b94 | |||
| b6c789dee6 | |||
| 8d3d636329 | |||
| 6f6395bad7 | |||
| b8c8d2a02e | |||
| 98104a3c03 | |||
| 8f4c65ec8c | |||
| 341a0452da | |||
| 6afb8de3dd | |||
| 0e62ccc7aa | |||
| 09282a9a62 | |||
| 18b3ab5610 | |||
| 699a658df9 | |||
| b5e255a384 | |||
| 67ccd2c1fb | |||
| 898b072e68 | |||
| 34156af403 | |||
| 61a0126278 | |||
| 1ce1904c89 | |||
| 7c9c278cc4 | |||
| 450507a812 | |||
| c074c835d4 | |||
| 2e989fbe83 | |||
| b1b325d00b | |||
| cf00650c6d | |||
| e6ab28365f | |||
| 80fd2a1a18 | |||
| 84160b2f07 | |||
| fbc2c2b481 | |||
| 57a5005197 | |||
| 9350c5513e | |||
| f59516cc6e | |||
| 88078ff813 | |||
| 281de48ed4 | |||
| 3c6d6bf688 | |||
| 8ac0ce399f | |||
| 80458e24bd | |||
| 6ab966ee2f | |||
| 166477ae27 | |||
| a719065b8d | |||
| 36599a2984 | |||
| d9c9289d65 | |||
| e5579b2c33 | |||
| 618028503b | |||
| 2f6756eddf | |||
| ad53894ea1 | |||
| 086954fb9c | |||
| f243ad14e0 | |||
| 2e5822b7c8 | |||
| 3d468339b3 | |||
| b4c14fc78d | |||
| d9584174ff | |||
| 36e00e8d6a | |||
| 5e69b54eb0 | |||
| 5a8c60a8bc | |||
| 3ff41f2b43 | |||
| 17cab0d3a8 | |||
| 0fac9e367d | |||
| bf0bcf8967 | |||
| 2e06ae01a1 | |||
| 288a32cc1e | |||
| 26fc3a1966 | |||
| 9d257ebecd | |||
| 1a046a9bcb | |||
| 7a9c869ac5 | |||
| 572fb0993c | |||
| 9beee3ed65 | |||
| ab19e25586 | |||
| 07d7d16418 | |||
| 5e1e748c71 | |||
| 6651ad0d45 | |||
| 288beae874 | |||
| 32ce771911 | |||
| d944ecaa21 | |||
| 5aeb6ade72 | |||
| 107b4b83c1 | |||
| 0d61e29ecf | |||
| 781d4f570f | |||
| a4d4f1bc2e | |||
| 048e27f03f | |||
| 8c434703fb | |||
| 3cc900ffbf | |||
| 7b6aa3ba5a | |||
| aa933df525 | |||
| a0f137936d | |||
| dcbfc963c1 | |||
| 91fa78d740 | |||
| 89eb857c14 | |||
| e07d17c472 | |||
| 4c2c320b9d | |||
| 56c574c928 | |||
| d2aea86957 | |||
| 80e061115f | |||
| 4299627f5f | |||
| 6a722102c5 | |||
| f22f3361d5 | |||
| 4dec8c265d | |||
| d990e5b909 | |||
| fb48636510 | |||
| 1ad6722e6d | |||
| 557ef2ef79 | |||
| cff2caa07a | |||
| 237fe84c54 | |||
| 078cb0855f | |||
| ecba67da6d | |||
| ea05e1f559 | |||
| d3a55c8b1a | |||
| d6b17678ec | |||
| 33e287a543 | |||
| 08f045a02b | |||
| e8c14dbb58 | |||
| bf48eee705 | |||
| 8f4c75ff2b | |||
| ee75d672e6 | |||
| e140897313 | |||
| d1671f0ddc | |||
| 2730486ba5 | |||
| 49e4515785 | |||
| 819c524f51 | |||
| 6d968f9044 | |||
| 23fa9e8d7f | |||
| 59a428d549 | |||
| 70c213ad22 | |||
| aad6402fdb | |||
| 5ce1cda2d0 | |||
| ba60fc7581 | |||
| 0344e8cf1b | |||
| f840aa80f8 | |||
| c17540e191 | |||
| 309ef807ab | |||
| 61e05e92a8 | |||
| 1e5d6a5d52 | |||
| ff831678e8 | |||
| 910be21e93 | |||
| 89055f8655 | |||
| b9ccc28baa | |||
| 5a3d450482 | |||
| 047e7a72f2 | |||
| 3a9d09ea63 | |||
| ee3d3808ef | |||
| 8f5a6b7c95 | |||
| 840811b464 | |||
| 567e1c46db | |||
| cfe0c2a986 | |||
| 68546acf2a | |||
| 5220361151 | |||
| 076e01dbfe | |||
| f15ed08b6a | |||
| 828b96b2d9 | |||
| 3100437651 | |||
| 20880a6bf6 | |||
| 2eff69fe9f | |||
| 5f035db0a9 | |||
| e4a7e9d6b5 | |||
| ab14b561f5 | |||
| 5ce4734a70 | |||
| 1ae2089253 | |||
| 3c21e9d413 | |||
| 9616d99640 | |||
| 2ef11e5ad0 | |||
| 27497451d9 | |||
| 94fd3841aa | |||
| 225dcdeafd | |||
| 2c9f2e0d68 | |||
| a9f74ace5a | |||
| 6dc5b58d8e | |||
| 88c794e710 | |||
| 61f2fb28e0 | |||
| 1df4dca4bb | |||
| 6278bb8665 | |||
| 4229cb7fb6 | |||
| 5778200c8f | |||
| 5c1c511718 | |||
| f9c4dd2457 | |||
| 3bccd52196 | |||
| 0c23da7b02 | |||
| d577cae393 | |||
| 24228b4424 | |||
| 8dc4490169 | |||
| ef1cdf6ad2 | |||
| e054b9a54c | |||
| 32616aa441 | |||
| 0ee6336b02 | |||
| 9a477a9270 | |||
| 976ae502bb | |||
| c4c12836a4 | |||
| 5a70c0d7be | |||
| 60a80a2996 | |||
| ce88c6ccc3 | |||
| b42edfe7a7 | |||
| 0cbcfbd273 | |||
| 8ecec93e67 | |||
| 49403771c9 | |||
| 50215dab9a | |||
| 58b9a42c84 | |||
| d7264f8c22 | |||
| bef6549805 | |||
| 6f65350269 | |||
| 5644a40a03 | |||
| 920ddf43d7 | |||
| 4a5f534a65 | |||
| 24031f12db | |||
| 22361d785d | |||
| 8c5ce6149f | |||
| 516b0b4464 | |||
| d22052c612 | |||
| b4ce5342c0 | |||
| 0d5792405f | |||
| 48a590df4a | |||
| c264332994 | |||
| cdd740015c | |||
| 07ad81969c | |||
| dcdd4bb20b | |||
| c98fac30b6 | |||
| 1f8372f5e5 | |||
| 616ecf77b0 | |||
| 656c81a1fa | |||
| 290a377ef9 | |||
| 05731c9f72 | |||
| 3108bc5ccc | |||
| e687a3403e | |||
| 753ae3d7dc | |||
| c9a2fdcb29 | |||
| f84634e978 | |||
| 89821b91b0 | |||
| 347b49f564 | |||
| 5ad9f507ba | |||
| f8f555b4b6 | |||
| 786df450e5 | |||
| db9d5c9d43 | |||
| b447cf5c1c | |||
| 4e6b75d650 | |||
| f44b7ed1d0 | |||
| b0b7a0a618 | |||
| bf9f3895db | |||
| f3555a12ce | |||
| b2acdadcea | |||
| 9eff471afa | |||
| 8979586404 | |||
| bfe514b7d4 | |||
| 752bfffb11 | |||
| 10f5bc8cbe | |||
| 565ff36d4e | |||
| 401bd91204 | |||
| 5d7c197c89 | |||
| 8e97be8ef4 | |||
| 733ad52684 | |||
| 5ccf0df308 | |||
| a3a8937ba3 | |||
| 2662e8f715 | |||
| 28b2005068 | |||
| 7c9631c1b0 | |||
| 4352989242 | |||
| 73bb73a04a | |||
| 20a1d40d99 | |||
| e10b178565 | |||
| 46b0b3a6ef | |||
| f2aed08d51 | |||
| c2c8cf919e | |||
| 9ebe23e91b | |||
| 3d96749d38 | |||
| 1dc369180c | |||
| 8d3a326216 | |||
| 1d3ad38187 | |||
| 1b22205f74 | |||
| 826fee4590 | |||
| f0929729a3 | |||
| 98ed2e01cc | |||
| ed82a5aa19 | |||
| d7b2476473 | |||
| ee162f468a | |||
| 0d5a30b214 | |||
| cb6678fa71 | |||
| 10011d3886 | |||
| 0367d9ec2a | |||
| 26f520ca4a | |||
| e282142d3f | |||
| 7ba10db7d4 | |||
| f6de373388 | |||
| 8683fc9fe4 | |||
| fd0920c808 | |||
| 9922294507 | |||
| f42ab45e1b | |||
| 7a131880e5 | |||
| a446fc0f20 | |||
| 202c26acf5 | |||
| f0b2acb4c7 | |||
| 102c90c4e8 | |||
| 7c484d8e96 | |||
| e9f0f7d1bc | |||
| f37ab53eff | |||
| 97b0b98605 | |||
| 1ab34fa77f | |||
| b64ecc7c6f | |||
| a11fc214e9 | |||
| 61c48602e8 | |||
| 452d59dcf6 | |||
| 5e976c08af | |||
| f1cce76e2c | |||
| 872fba1103 | |||
| 944f5950ca | |||
| bfa87a2131 | |||
| 0e75c80627 | |||
| 2c25f64652 | |||
| 45cf00bd04 | |||
| f6113e85c7 | |||
| 2c90bba774 | |||
| 51b0750a3f | |||
| 6eab985b1e | |||
| 81a9b8d158 | |||
| 9519f6418d | |||
| 9967a5dc66 | |||
| 9382055bf2 | |||
| 604f52762b | |||
| ae88a4d20a | |||
| b5a27226cc | |||
| 2c71324381 | |||
| 207ba7ec8e | |||
| e56b8edc0a | |||
| 8ab0a0a14d | |||
| 4e01722ba6 | |||
| 87eaacea22 | |||
| 3ad4f05449 | |||
| 817be40959 | |||
| d18592eaeb | |||
| 0aae672e19 | |||
| 0a6cd89090 | |||
| cfd9a01da7 | |||
| 942aa93f57 | |||
| 763c0f4a3d | |||
| 7af3033f8d | |||
| 91d8451ab3 | |||
| 6aaf3f0f02 | |||
| 226a774ab9 | |||
| 19cf3bfb9f | |||
| 67bbe21513 | |||
| b668c6e37a | |||
| 71762ef837 | |||
| b1524d245e | |||
| 8b39b01269 | |||
| f7849d2956 | |||
| ac746f199b | |||
| af4c35069b | |||
| fea28351f9 | |||
| bb124d3274 | |||
| 6cd1b82ada | |||
| c701617fbb | |||
| 405c954b65 | |||
| 5d84c426fe | |||
| 083ba2fe19 | |||
| 1024bc5a75 | |||
| 9553c19b33 | |||
| 2cbc9a07cb | |||
| ab97a9d613 | |||
| f1a7fd0d50 | |||
| e9d7efbc5c | |||
| f0f03efe17 | |||
| 6e5d334874 | |||
| 6822628994 | |||
| 98d9fd8c32 | |||
| e2cca60853 | |||
| e80b313a7b | |||
| b09b95ef24 | |||
| aec45d04f7 | |||
| 87d037cb0a | |||
| f6baf06164 | |||
| 7e75845851 | |||
| 2a11932822 | |||
| 80fee92037 | |||
| d0c02a801a | |||
| 9e13c64408 | |||
| 826963bf00 | |||
| 39b6ede1e9 | |||
| 066d853156 | |||
| efae529fac | |||
| 934c0b9093 | |||
| f02992dd4d | |||
| 10011bd6a3 | |||
| a44ee913c4 | |||
| adccccbd7a | |||
| 05b1b2be36 | |||
| 7cc35a2cbe | |||
| 8d479b6e34 | |||
| 74d300f048 | |||
| 1dd1fe8994 | |||
| 03115e5e53 | |||
| b1c07834be | |||
| b9da3fa30e | |||
| 42ff3d8314 | |||
| e63aab95d8 | |||
| 9123dcb365 | |||
| 7567e91878 | |||
| 1b1bdea3c8 | |||
| 2df95c1712 | |||
| 4ad1cd2968 | |||
| 0ecfdab463 | |||
| 75276f5a44 | |||
| 4585d2816b | |||
| f8f94f2a6d | |||
| 2c8448d147 | |||
| ea1d051cfb | |||
| a38e43213d | |||
| 6cac8fcd6e | |||
| 8e65c78869 | |||
| a3899b68e1 | |||
| 1187f91063 | |||
| 7c288a5ff9 | |||
| e0dae44c7d | |||
| 754498958d | |||
| ec15978e26 | |||
| 469167df66 | |||
| e7c43a3f32 | |||
| 24989e73ae | |||
| 13427b9f70 | |||
| adafefecd4 | |||
| 6f96b069b5 | |||
| 6c1b4e3a36 | |||
| 21343ffbd1 | |||
| 4f94deefa0 | |||
| 332078e6c1 | |||
| ff0d6326d3 | |||
| 8d451217a3 | |||
| f21d69339f | |||
| c77cead9ae | |||
| b334d40998 | |||
| 4e4a976050 | |||
| 9d7d4c6902 | |||
| 7222171c5b | |||
| 361732a463 | |||
| 1ebe8a6f4c | |||
| a98942a361 | |||
| 0bc89cd40f | |||
| 2ae86ab5bb | |||
| c707bcf0f6 | |||
| 10040ba9fa | |||
| 7afda1295b | |||
| 6d6e8613cf | |||
| 3651fffbee | |||
| 8d03b23f46 | |||
| fc44c801f2 | |||
| 6056c14926 | |||
| f465193b9c | |||
| 09c9c28028 | |||
| f1130eb63a | |||
| db80cec168 | |||
| dd9a3858d7 | |||
| 38029d1202 | |||
| aac2879652 | |||
| 8c9fc3ddb5 | |||
| 33e04d0cbb | |||
| fbb5fd41fb | |||
| 43a5296dd7 | |||
| 345ff1aa66 | |||
| 56e3449db6 | |||
| 1372c24535 | |||
| 409c5f7b75 | |||
| 83d0db0607 | |||
| 91b6c4412d | |||
| 09eefae808 | |||
| 80b3bfea51 | |||
| 516298b5b2 | |||
| 8edab98163 | |||
| 58da095bcf | |||
| b9633691f4 | |||
| 7ec1d8ee5f | |||
| 83a1374e79 | |||
| 5ef00bac92 | |||
| 95c4b3862b | |||
| eeaf012cdc | |||
| 11120a3765 | |||
| 4d0acb30ba | |||
| 4dbe8d29d9 | |||
| 0ca4ff4fca | |||
| 8be1651c6b | |||
| af2db86d1a | |||
| 57c834f88d | |||
| 65fdebde20 | |||
| b58e42ebf3 | |||
| b2d45f598b | |||
| 09c4e690c6 | |||
| 67ba481dca | |||
| 710a62c2af | |||
| 5a9eed0a5a | |||
| 354e16e462 | |||
| 1d974375a0 | |||
| 1c40af3eef | |||
| daa8c4cd67 | |||
| d5da4441cd | |||
| 80aea0c82d | |||
| 14836eeb0d | |||
| 85e9883d3e | |||
| 80ca73e491 | |||
| 22323f606d | |||
| 01b65eb678 | |||
| d1d94c37a7 | |||
| 838a24c8a5 | |||
| 3f380b0839 | |||
| 7fdf1a1d7f | |||
| c2793fe29b | |||
| 38596d017f | |||
| 24b9ac6a68 | |||
| 9a5ed64fae | |||
| c2af96e7cd | |||
| 104cadb0b3 | |||
| 6814adffcc | |||
| 20c11e381e | |||
| b5952f16eb | |||
| 5b6878e5de | |||
| 89a25bcf39 | |||
| d0cd512be8 | |||
| 3543dea0fb | |||
| 1949e25ccb | |||
| b715ef3bfc | |||
| 954050df81 | |||
| e4aa7f10fa | |||
| 2afd0e2acd | |||
| 0829237166 | |||
| 541975f038 | |||
| 01bf58ab97 | |||
| d99b2c25e8 | |||
| a31df5ff81 | |||
| 63e5cf2e60 | |||
| 7beca048e7 | |||
| ec998dc1ac | |||
| ddc54c8811 | |||
| 72e306935f | |||
| 96a7c7f4d1 | |||
| 9c65d655b8 | |||
| b108f2241b | |||
| 9439acf300 | |||
| d181e66d83 | |||
| a87c3f2c77 | |||
| 2834f6077e | |||
| 918013ccb3 | |||
| 4c4672c6c1 | |||
| b3991574c7 | |||
| c881bcbe59 | |||
| 89aa4a8bdc | |||
| c5a4f63670 | |||
| 1b97582975 | |||
| 9b7aacf3ea | |||
| 47b9ee557e | |||
| e40e0bfa25 | |||
| d56e3a3617 | |||
| 78fe6d47ba | |||
| 995cf51ae3 | |||
| d838ff2f2e | |||
| f2f07ff534 | |||
| 8cff68ca64 | |||
| eb5331d34a | |||
| f425185575 | |||
| 9fc352a5a4 | |||
| e85ddc1aa1 | |||
| b9be7510f8 | |||
| f4497acd48 | |||
| f73a0cce72 | |||
| 254ba1f089 | |||
| 0a179e4eed | |||
| 0ac63b2678 | |||
| 1d13d0a553 | |||
| fc6ff016a7 | |||
| e378b79fbc | |||
| 7e377297d7 | |||
| 00a02921dd | |||
| b5d4c11f6f | |||
| a0bc959850 | |||
| a4b0f6c202 | |||
| 65cf928afe | |||
| cf7fd315b6 | |||
| 679bdf36b1 | |||
| d86a3b3dc2 | |||
| e07e2cd359 | |||
| 8140d7021a | |||
| bdbc5e3161 | |||
| bb9013541b | |||
| 1668153acd | |||
| aeba7674f8 | |||
| 5b0d105e21 | |||
| feb54d0629 | |||
| 3284fe8f31 | |||
| 18cb394884 | |||
| d0bce2949e | |||
| a0e80772cd | |||
| e44595521d | |||
| fdf647eb32 | |||
| 71369bd2a0 | |||
| 36b1f43f4c | |||
| a8bc1df3e7 | |||
| a96869f547 | |||
| 77b030199e | |||
| 0e1c6c0ba7 | |||
| c397422d3b | |||
| 15313826bf | |||
| c6405b9013 | |||
| d748d43efc | |||
| d54edb93d6 | |||
| b8ca6671fc | |||
| cb7fb646ba | |||
| aa82c8a253 | |||
| aae92649b1 | |||
| a9f5c64204 | |||
| 1392baf1eb | |||
| 0ec50bb570 | |||
| b60473d7ae | |||
| 014fc45c15 | |||
| 4b4fb33d8f | |||
| 35e3458fb4 | |||
| 8f42153bee | |||
| 2f04d34bce | |||
| 09566c02ea | |||
| d714ef37d9 | |||
| fde07d26e5 | |||
| 9547824aaa | |||
| 5a01be1ee3 | |||
| 5dc4606657 | |||
| 2fd3238576 | |||
| c1bcfe8304 | |||
| a3642b204d | |||
| 8243da69f6 | |||
| 6d5987b2e0 | |||
| a2fdc3e876 | |||
| f92b66a469 | |||
| c3d256c42b | |||
| fdc792cb82 | |||
| a16fb31e6e | |||
| 4d8a1b5b6d | |||
| c382f07b05 | |||
| 9f6a7d065c | |||
| 11aa75ecbe | |||
| 05ce9c6eda | |||
| 15aaf2863c | |||
| 019063e6f4 | |||
| ea79948122 | |||
| 7a0f27e3cc | |||
| 4f75a89633 | |||
| b3f19ef628 | |||
| f16e312319 | |||
| 056da0ef70 | |||
| ca5f781531 | |||
| 53c96b2540 | |||
| 9712bdf5f0 | |||
| 0678c26627 | |||
| b52e240025 | |||
| 2fa73f7a8d | |||
| 2cc23b6d6b | |||
| 9a617226b3 | |||
| fbfc015d92 | |||
| 3e4c94e2b4 | |||
| 95e6fef3d1 | |||
| 1da471e136 | |||
| 4dba95c000 | |||
| 36477a832c | |||
| b4aa8f0c9a | |||
| 6a974d5ef0 | |||
| 304eda9f8c | |||
| 581f2e3d15 | |||
| be2d317325 | |||
| 9f6bfeb839 | |||
| f4f5f79af7 | |||
| 92bb2fb23d | |||
| 3c406c12b4 | |||
| 81d4ac3ed2 | |||
| 32bdae31a8 | |||
| 84c16c4a39 | |||
| b8b3d05f5e | |||
| bac09de23d | |||
| b0bf9604bb | |||
| 688531f0a7 | |||
| dfc7877f69 | |||
| e00116a0e3 | |||
| 2ab287e2a9 | |||
| 1e0da09b2f | |||
| 0e7a5649cc | |||
| 30009e45da | |||
| f9a668cb41 | |||
| c848f366de | |||
| 25daab2f34 | |||
| 7170ab7239 | |||
| 063b3bb8db | |||
| 6eb6a7b115 | |||
| d0972348b9 | |||
| 0e70af77c6 | |||
| 4efca78602 | |||
| 87d10bd6f5 | |||
| 0f82aed4ce | |||
| 58f10ad7af | |||
| 68dcf87aea | |||
| c2f85deb11 | |||
| 0dd3a52cc8 | |||
| c07c73c649 | |||
| dbde5f773c | |||
| 68bf038205 | |||
| eb7f66c89e | |||
| 58ebde2982 | |||
| 604a671549 | |||
| 5286b53334 | |||
| 4359ca28df | |||
| 8b685436de | |||
| 4db26f9f79 | |||
| ff8a58c7bc | |||
| 6f67c7bfa2 | |||
| e9f5bd9bfe | |||
| 56e213d654 | |||
| 98323de64c | |||
| 4a13712b1c | |||
| 0387436111 | |||
| 7685ead000 | |||
| 8665d66923 | |||
| 9a808602c4 | |||
| 813e553dbb | |||
| be050a7d57 | |||
| 065675697d | |||
| 8f6832fc2e | |||
| bdb154a6e5 | |||
| f557289274 | |||
| a5627a1b52 | |||
| 33f20d54cc | |||
| dadd41cb5c | |||
| 35e27e4f61 | |||
| 84839bea44 | |||
| 1342897858 | |||
| c32efb8db8 | |||
| f9ed412e4e | |||
| 6ae3ad508e | |||
| 24af702b41 | |||
| a57ff20f35 | |||
| 39e710deb1 | |||
| 3b6fa73ac0 | |||
| e2dd66d450 | |||
| b1b53a1eae | |||
| 6f73345f39 | |||
| c7b4b3bd3e | |||
| 98d543e3e5 | |||
| 4de4e958a0 | |||
| cc5e92ec8e | |||
| 6cb9dfaa85 | |||
| 8790166ac1 | |||
| 3b97e2146d | |||
| 0bb1cf002d | |||
| 307c7ebc9d | |||
| cc1b41995d | |||
| 730d60575e | |||
| 1b96297cc7 | |||
| 128c554543 | |||
| 1b5ab6c378 | |||
| e4961feffb | |||
| eb5f257b8c | |||
| e271e89835 | |||
| f5009f76f4 | |||
| 8d0064763c | |||
| 7010a13648 | |||
| a3e63e03d2 | |||
| 2ae3ea346f | |||
| 8542d433a2 | |||
| 03984f96d4 | |||
| eab019c577 | |||
| 179f11f55d | |||
| 812395b21b | |||
| 62b0940766 | |||
| 5a21e63d0b | |||
| 24ef105732 | |||
| 589c4f73d2 | |||
| 55fdc48d5d | |||
| 4d45a902bb | |||
| 69bac2ec1e | |||
| 122ec140e8 | |||
| 6a0adf7433 | |||
| c1b2aaec9f | |||
| a49acdb2e4 | |||
| 9b67fbe8d9 | |||
| 2d13215f1f | |||
| a77c3aae93 | |||
| 164937b454 | |||
| b0a8f3d207 | |||
| 77cc0934be | |||
| 718890cfad | |||
| 418adcf891 | |||
| b96f878d69 | |||
| 22b8622c67 | |||
| 3dc9416da6 | |||
| 5e5b674c17 | |||
| 3656eab8bf | |||
| 25ca950dd0 | |||
| 8fca84e4bd | |||
| 56579f440b | |||
| a59311f795 | |||
| 042c89039c | |||
| d94482827a | |||
| a8dab5653b | |||
| 1d1200a3f2 | |||
| 4d110ebe7e | |||
| b300f0d10c | |||
| 6dc4dc8f49 | |||
| dfae6cf89f | |||
| d7f18bdd8b | |||
| 05b102722b | |||
| ef954ee68f | |||
| dbaea9f87d | |||
| 64768ec2f9 | |||
| 14ee17de47 | |||
| 034b8956a2 | |||
| 1a3f0e332e | |||
| fc36e86db7 | |||
| 60b4bc1a7e | |||
| 9fdc8df8bc | |||
| 212b97fa20 | |||
| 704fbaced8 | |||
| 575a162f8b | |||
| d2e0844493 | |||
| f2baf3fafd | |||
| 916fd039ca | |||
| e248b6d8d8 | |||
| 936de68622 | |||
| a99257e758 | |||
| c89d77dd06 | |||
| 3138865d69 | |||
| 08676a675a | |||
| be53b31712 | |||
| e1ddb95250 | |||
| 4d29ebd647 | |||
| fd58df4729 | |||
| 5078818295 | |||
| 7181df0479 | |||
| 6c618d7760 | |||
| 17b8cf19b7 | |||
| e018f8341e | |||
| 59b5f8cbbe | |||
| d6108a0722 | |||
| 1af7e59d88 | |||
| 7b425e9a9d | |||
| 596a03900b | |||
| b283644d95 | |||
| 808690c137 | |||
| 136c347586 | |||
| e81238038e | |||
| fcf6964d7d | |||
| bd75ad4576 | |||
| f970d8e539 | |||
| c49010b4e1 | |||
| 146093d81e | |||
| 11ccbf1913 | |||
| a4a334a18a | |||
| 387a37e4da | |||
| ebad304aa9 | |||
| 8b557a0cb9 | |||
| 40b808e73d | |||
| a8b57a1ce9 | |||
| 35315843f2 | |||
| 27b9d3b94f | |||
| 0010ac5a40 | |||
| 884808f34e | |||
| f75ed07497 | |||
| b707d6f3c9 | |||
| a2d4a4a906 | |||
| 434d743d99 | |||
| 30f16b05fe | |||
| 92a88f4416 | |||
| 5c9c122af2 | |||
| 620d5ce578 | |||
| 363e1cee4b | |||
| 93f576772a | |||
| d4612bae92 | |||
| e01af27008 | |||
| 657fe0a650 | |||
| 9a6ec5548e | |||
| 0807509ea7 | |||
| d9d1c4e360 | |||
| 2135e5b066 | |||
| b69eb10ae0 | |||
| e1512b6f54 | |||
| 1b8e8215d6 | |||
| 9b44e36e7b | |||
| db1ca08c2e | |||
| 557d3243c3 | |||
| 785942b94f | |||
| 3df7caa838 | |||
| aef2c52630 | |||
| dccad3055b | |||
| c629923a80 | |||
| b4f1fd5b25 | |||
| 267897ce74 | |||
| 022bf9d0ef | |||
| 61c759e0c4 | |||
| cfb3ce0c60 | |||
| 72396c5a98 | |||
| 12f231b886 | |||
| 6aeed24296 | |||
| d8b6e09bc0 | |||
| d95975cade | |||
| c4208a4690 | |||
| 7c7a6df6e4 | |||
| 791c058ef8 | |||
| c847aea0a4 | |||
| e56164aa5a | |||
| cfb5e909a9 | |||
| 071444a9e7 | |||
| 34ac972130 | |||
| 97b5cf04f5 | |||
| 0d50d730d9 | |||
| 3a7fd0bcc9 | |||
| f0edea5d52 | |||
| 9c6b07df99 | |||
| caacf461ab | |||
| 5bdbc75522 | |||
| 0d3e6b1d0a | |||
| a122e25cba | |||
| d7b287bfed | |||
| ba4f585318 | |||
| 3f859723a6 | |||
| c820d0e62b | |||
| 7a47032a96 | |||
| 2db4dd6a40 | |||
| f58e2b6dce | |||
| 859a53e79a | |||
| ad0edc6329 | |||
| 002fb7a35e | |||
| cc62a20a5d | |||
| ec7e965dfa | |||
| 9c3f5406a9 | |||
| f4ec6948d2 | |||
| 9a51c3be0f | |||
| b1ee54522a | |||
| c14d13440f | |||
| 8c84640484 | |||
| 0d8917ced6 | |||
| a006eb489d | |||
| f2941e04d3 | |||
| 2728546660 | |||
| eeb7c80518 | |||
| c8c40360ad | |||
| 79ab656217 | |||
| 5c250da388 | |||
| 505e0eb3a2 | |||
| 388444e51f | |||
| 08d7a9aa14 | |||
| f650ae7f18 | |||
| 6d138ae905 | |||
| 956678c08c | |||
| 911c854365 | |||
| 3c5dc17e3c | |||
| e709cc4cb1 | |||
| da7825e3e3 | |||
| 4039dc7968 | |||
| e345c4cc9e | |||
| a08cfa436e | |||
| 7207efb4da | |||
| 481611ff33 | |||
| b67cd37a38 | |||
| 6e8547f0c8 | |||
| 96930d7ecc | |||
| 23f2c8a251 | |||
| c5372d1405 | |||
| dcfbed5f30 | |||
| 895ab8d18a | |||
| 8b5d05739f | |||
| a8f6202302 | |||
| 5d40fdf277 | |||
| f35c96e118 | |||
| 8f8d6f81ab | |||
| e195eec1c5 | |||
| 33846e46fa | |||
| 2ad03bcb9a | |||
| 371cd3b2e5 | |||
| b9307143bd | |||
| 36e44e902a | |||
| 4529fc0124 | |||
| ba07761de3 | |||
| 3b7ce69327 | |||
| cf927f61a0 | |||
| 61c32d99e7 | |||
| 19e39f6321 | |||
| f9e6655359 | |||
| debf0f495d | |||
| 3383ec2046 | |||
| b957e1a36b | |||
| c93f17051a | |||
| 5983f0262f | |||
| 96a8e74d38 | |||
| 6f3d488c3d | |||
| 74dcc4f9e4 | |||
| 8c4d3b93c8 | |||
| c411cf04cc | |||
| d1b25da408 | |||
| 08f765fa51 | |||
| 337cf90c4b | |||
| 17b930e13d | |||
| 639b600570 | |||
| 7a751b8f91 | |||
| 68621e0c07 | |||
| d2512d324a | |||
| 5abb02e93a | |||
| 573079c5a1 | |||
| 5bde320ac7 | |||
| 5f63d97e59 | |||
| bf2fe3faea | |||
| ea60f19e7a | |||
| cefc75a4ed | |||
| d8753aafb9 | |||
| ba5ad228cc | |||
| 0203f9cc1b | |||
| 4770be5a39 | |||
| 1bac395bed | |||
| e818f270cd | |||
| c4e2726622 | |||
| 74d8a09f31 | |||
| 618338165e | |||
| db494001a2 | |||
| a67213fb7b | |||
| 5d96b2cc6e | |||
| 72d0b097ab | |||
| 36d2957fb4 | |||
| b5de517aad | |||
| 41db0e3bb1 | |||
| e8d582269b | |||
| 80ef8ee890 | |||
| a65859f575 | |||
| 5724887785 | |||
| 8908aa7a82 | |||
| f83dd29213 | |||
| 99d90778f4 | |||
| 49279430fc | |||
| 030c20b12e | |||
| 5e943ef152 | |||
| 4ae057f626 | |||
| 9ebe4b55dd | |||
| 2f7403adec | |||
| 2777b496ad | |||
| f7a3dbf209 | |||
| d900093976 | |||
| 08250e266e | |||
| da2d1455d7 | |||
| b6c6c4c939 | |||
| 22179d82b8 | |||
| 343ce312f1 | |||
| 10677d6fb0 | |||
| 49a8aead9b | |||
| 274b0e48be | |||
| 4d8ffc5d99 | |||
| 4f3029e5b2 | |||
| a1b49f5fcf | |||
| 89d497a305 | |||
| 9e095a4bc1 | |||
| 024d052a7b | |||
| c312979aec | |||
| 773e621944 | |||
| ed4f33b565 | |||
| f8a0852dfc | |||
| 6dec750d3e | |||
| 3c98a5fb24 | |||
| 702ee3d350 | |||
| fcc2f3650b | |||
| e4ad622c01 | |||
| 458403eec9 | |||
| aaede2752c | |||
| 39d8c2cf04 | |||
| dd5c940d36 | |||
| 277f024bbc | |||
| 59ad1e5e36 | |||
| 02c4b21d3f | |||
| 33ae5445be | |||
| 5ed06871b6 | |||
| e98eb8f1eb | |||
| ebedaeb3b0 | |||
| 62aec63d1d | |||
| 3c25e87e8d | |||
| 08d16ce7c2 | |||
| 2cb3808326 | |||
| bdb6f0c0aa | |||
| 5255bf13cc | |||
| 3588e1e8d3 | |||
| 8fa8360e99 | |||
| b305cfd268 | |||
| ff10287d05 | |||
| 7a7708403f | |||
| ddabd0ee75 | |||
| 5a26704c32 | |||
| 7ccf36a896 | |||
| e9a84dd7dd | |||
| b00510855e | |||
| 2cd9079692 | |||
| 3e4b1652fc | |||
| 878330b4fb | |||
| 9a85ad1f6b | |||
| f76f9c7f84 | |||
| 3426832f2b | |||
| 10fd51498c | |||
| 49c581ed35 | |||
| f095d89980 | |||
| 1609f1a499 | |||
| 88bd51e2da | |||
| 74388fe0b9 | |||
| 7f5356100d | |||
| 84d2d00a30 | |||
| 31dddfbb60 | |||
| d6da161b13 | |||
| 9de7be1cb4 | |||
| 5410aae8fc | |||
| 86bf6bfc62 | |||
| 0807146aab | |||
| 591d8a8ab1 | |||
| b1d4e28027 | |||
| 44363f05ac | |||
| 452af43916 | |||
| 70ba2f7850 | |||
| a364fe5031 | |||
| ca6765c8e7 | |||
| 6bfa281dc5 | |||
| d8ee61bfab | |||
| c6763dee2d | |||
| 0e6b0d3eff | |||
| 8bbfee334c | |||
| f806e4cce3 | |||
| 209ba308bd | |||
| 4cd9088a66 | |||
| ac5e2e5c73 | |||
| f1329d2847 | |||
| 27faefc64d | |||
| 0fa7e61dc1 | |||
| 5a3f14ae51 | |||
| 4e61185136 | |||
| 6ee06d5dae | |||
| 2c344a0bc0 | |||
| 315c83e4c3 | |||
| 9e4bc582cb | |||
| fc6aa1f91f | |||
| d4bea34423 | |||
| a551a2d288 | |||
| 4b0c59b174 | |||
| a0840d2a08 | |||
| 308ccf470f | |||
| 4021b6eca1 | |||
| 061695f922 | |||
| e803dcd325 | |||
| 128796bd36 | |||
| 775dedc338 | |||
| 45c9038954 | |||
| 8acf962864 | |||
| c3fc38639e | |||
| b60b75c8da | |||
| 0f7edec73b | |||
| 321277826f | |||
| 6e752af2c0 | |||
| 0717ae39db | |||
| 7bc5902ea8 | |||
| a28e1ed5e0 | |||
| 43d9e129a6 | |||
| b516019ddd | |||
| e4c20d677c | |||
| 33e183b802 | |||
| b884f8fe11 | |||
| 2cba83f1dd | |||
| a9ee9031c3 | |||
| c3717f6979 | |||
| 657d4dd705 | |||
| 17356ffd79 | |||
| c4be75b5bd | |||
| 57422d0759 | |||
| d2454201b4 | |||
| 3a92a69693 | |||
| d733c9ccc6 | |||
| 3e15e09c07 | |||
| 0592a41d4f | |||
| c32e33f804 | |||
| 616ffb8f79 | |||
| bc771a3a44 | |||
| 539d1a2d4f | |||
| 4d8cea0bb4 | |||
| 8b46262e93 | |||
| eb9a077520 | |||
| 3d3a224402 | |||
| e1397a6dda | |||
| 8f49aae979 | |||
| c0a13f01d4 | |||
| efcebc616c | |||
| 902867c3bc | |||
| b7abd372e4 | |||
| 147ffc0210 | |||
| 1b2ccb6cee | |||
| c58a6b9047 | |||
| b787fb18f3 | |||
| 17cce9c914 | |||
| 90299e348c | |||
| fe25a1bc54 | |||
| edbe1851b5 | |||
| ad6c5a4f00 | |||
| 4971787482 | |||
| 56d2ec9c22 | |||
| 106ddc9541 | |||
| 4d93e39fa9 | |||
| 54b41b15c2 | |||
| 54ca42a903 | |||
| d7cc8a052a | |||
| 5165f11460 | |||
| b47ce4fb24 | |||
| 9b1f7f566f | |||
| 10295b000a | |||
| c06d734d5e | |||
| 49a69193d8 | |||
| 7852804a9c | |||
| 415dda37a4 | |||
| 179d339afd | |||
| 858c1a7353 | |||
| 0b42b81558 | |||
| f9678dec2f | |||
| 82642b295c | |||
| ba3d84a924 | |||
| 96e2f934a3 | |||
| a68ade2b3d | |||
| 4fcdeda447 | |||
| dc03835742 | |||
| 50430e6b27 | |||
| d130dd6d5e | |||
| 793cc989de | |||
| 27d8c4d67c | |||
| 48f493a9f5 | |||
| 04992ee3fb | |||
| 4d8e2a1279 | |||
| 2af7b6b6f1 | |||
| e59351566d | |||
| 05d10b73c3 | |||
| 41e192c6a5 | |||
| ea42ab7624 | |||
| 2d9035d90b | |||
| 0ae853c119 | |||
| 3c0fdff7b4 | |||
| eede2bbd46 | |||
| 5c31687a0f | |||
| 6b654d3c2d | |||
| 91cbe45839 | |||
| 7883d4a97f | |||
| 9f4547cff8 | |||
| a98106593d | |||
| c625b3f08c | |||
| 9e7f09c21b | |||
| 616caecdf1 | |||
| cee19c5128 | |||
| 67db41a525 | |||
| 3ea3e55d17 | |||
| 4959a28485 | |||
| 6d2482a98e | |||
| 4b23b842bb | |||
| 07bebc8808 | |||
| 027d7f7a5b | |||
| 6baa0fa047 | |||
| 8425fac543 | |||
| 7b2ac7b9e9 | |||
| bf071be247 | |||
| 6c05a0af8a | |||
| 0e292c64c4 | |||
| 725f8eecdb | |||
| 521a673094 | |||
| d917f0e37d | |||
| 7ed5b1744f | |||
| c9ab2a242d | |||
| 13532cba14 | |||
| 3fb2bd3362 | |||
| e80c3a1c5a | |||
| e04d26307e | |||
| b8f74e1c98 | |||
| 0851050392 | |||
| b84882d9d1 | |||
| cd37a7618e | |||
| 64a7cfac3b | |||
| 1ee7ba54f8 | |||
| 6bb18f8800 | |||
| b26b854963 | |||
| 7d58361ced | |||
| a3723f3d06 | |||
| 78d1cd0cfb | |||
| d41366a417 | |||
| a2347150a2 | |||
| d33f23dede | |||
| cfca2be1b2 | |||
| 73f07c1392 | |||
| 4541e9ddc3 | |||
| 972271a1a9 | |||
| e97d92a8ac | |||
| 9a73e352d1 | |||
| 08f09f81fa | |||
| c72609013c | |||
| 29a6434fdc | |||
| eb2ea9950a | |||
| e307ded192 | |||
| 2d6c997b38 | |||
| 232a80a848 | |||
| 083f8faa46 | |||
| 0fcf978ffe | |||
| c1360267c6 | |||
| 084bea6b15 | |||
| 2032dd88ba | |||
| b11b1be432 | |||
| b743b34fab | |||
| 950d10091d | |||
| af0e02b9a2 | |||
| 1332147c4a | |||
| f07cb1e7a3 | |||
| 53dbdd115f | |||
| a217ed5574 | |||
| 531f947754 | |||
| c957e9483e | |||
| 623a706555 | |||
| 7e171576e0 | |||
| 0979b3e03d | |||
| 1131bfa751 | |||
| f9b87b94bf | |||
| 59ed2ec87f | |||
| 7b0b79e3a1 | |||
| 53f73e1201 | |||
| c62a1dfff0 | |||
| 61f8055493 | |||
| 000d7fd249 | |||
| 087de03a1f | |||
| a3ca6159fb | |||
| 5de6ee136a | |||
| d5a19f2b42 | |||
| e3ec5dd506 | |||
| 762748225d | |||
| 4db34e0c56 | |||
| fb078d05bc | |||
| f59edffa43 | |||
| 7aa0ddb71f | |||
| a0a6256c7a | |||
| df7e331605 | |||
| 8c23704e17 | |||
| 12abb1731c | |||
| 180293ebc1 | |||
| e2af33e136 | |||
| 42e68edc65 | |||
| 47e732c213 | |||
| 77a86d92f4 | |||
| 64a8a046c1 | |||
| 1f02cbddd3 | |||
| 5e7bca02b3 | |||
| 097f9549b1 | |||
| 45434b16e0 | |||
| 6af5ac2be1 | |||
| 34ff7efa27 | |||
| 8f4391003f | |||
| ecefb30f3d | |||
| a8162b57ba | |||
| b0edac4234 | |||
| 98c4045a71 | |||
| 24e90e2ead | |||
| 145e0217b6 | |||
| e5925fb1b6 | |||
| 9e416d02bd | |||
| 82b7068130 | |||
| 579ee36857 | |||
| 4f2d7a519d | |||
| a3642d92c5 | |||
| 224f36164f | |||
| 638c220ae8 | |||
| 51070b3e7b | |||
| 0aa2723063 | |||
| 1af66c8e8b | |||
| 7df8795d52 | |||
| a0e9ae7092 | |||
| 0f0d8e317a | |||
| 3d5ca7d5c4 | |||
| e33104fa2b | |||
| a2f1723642 | |||
| 93357cf280 | |||
| 767427c787 | |||
| 9377631896 | |||
| d08af094b8 | |||
| c307b1e6fb | |||
| d387d5b758 | |||
| c285dd666d | |||
| b37b382ea7 | |||
| a2cd755ffa | |||
| 340aedfe13 | |||
| 6fafa7a75e | |||
| 03df5aaf42 | |||
| 6d84db08a8 | |||
| 1a5e0d2a5e | |||
| 70d887bada | |||
| ee0ac00f80 | |||
| fdfb07ff2c | |||
| b648155170 | |||
| 59dc5299b1 | |||
| 357a63a4d9 | |||
| 94912c7542 | |||
| fae182b328 | |||
| 9ba2f3e33a | |||
| 5ca2bc5d64 | |||
| 442687b198 | |||
| 7e400d3e9c | |||
| e3ba739db5 | |||
| cd92a22f4d | |||
| d24ed98bcd | |||
| bcd224f534 | |||
| 1a93103e50 | |||
| 45ccf9d4be | |||
| 052a8307b3 | |||
| a0c0b9ea76 | |||
| 7485cf1a26 | |||
| 8931702f1b | |||
| 00fae3eb16 | |||
| 003e8e17be | |||
| edd9443d51 | |||
| b93a4c6792 | |||
| 30cf144090 | |||
| f17abef20a | |||
| 937438800e | |||
| 892fb6410c | |||
| 7008267e42 | |||
| 2e5e02472c | |||
| f9d37228cf | |||
| f48d52a489 | |||
| 7d8c8fa5bb | |||
| 96a739e22d | |||
| c3ec036009 | |||
| c7794e00f6 | |||
| 3316394f5c | |||
| c5d66989a6 | |||
| e6b886a511 | |||
| 9bdfb05ea6 | |||
| 52d02b32f7 | |||
| adff5a7705 | |||
| 60fb4090ff | |||
| dd28be0113 | |||
| 5a60bb8267 | |||
| 2749b710e6 | |||
| 55ddcde631 | |||
| 4d2bcfd167 | |||
| 1fe4cffd3b | |||
| 8f83752abc | |||
| 31be2ba4fb | |||
| dc156a2eac | |||
| 42050a5f17 | |||
| bcc7fcb645 | |||
| d96f427b83 | |||
| bba8d0a46f | |||
| a07a69e7de | |||
| cbc2f64e2e | |||
| ef622108c9 | |||
| 78559520ab | |||
| 61a8f31802 | |||
| 3357ccfaf3 | |||
| 92e3e0ef6e | |||
| ed76f51f4b | |||
| 7d569e1e3e | |||
| 16cf5b5616 | |||
| b260bcaeb1 | |||
| 3ffc481a54 | |||
| b9b38d82f2 | |||
| 9635d72cef | |||
| 288edae3d1 | |||
| ec90aafed1 | |||
| c023678c11 | |||
| cada1a6857 | |||
| 5eac2a91fb | |||
| eb295453fc | |||
| 28feed6ea2 | |||
| c6dc4054be | |||
| 6f901defd6 | |||
| 4cbc8676c6 | |||
| 0d587b6aae | |||
| a47bf7a835 | |||
| fce9e72851 | |||
| 6357fb26bf | |||
| d2aabde8fe | |||
| fdf67e17a0 | |||
| abb4137d4c | |||
| a237058e30 | |||
| 06851f50f4 | |||
| 54c1a49e1e | |||
| 12e47fb034 | |||
| c91897ae99 | |||
| 26f4479859 | |||
| c33314edfb | |||
| b083f6ab96 | |||
| 8d5e08b76a | |||
| a7019e2f11 | |||
| a7163f7a00 | |||
| a1f758cd7b | |||
| 946e4f39cc | |||
| 6e064eeafb | |||
| 400e34a4c7 | |||
| 780a0a9dd6 | |||
| c1b3d7779b | |||
| 2662b3ec49 | |||
| 042a175d16 | |||
| 5e50ac91ff | |||
| faac6f677a | |||
| 46d02744a1 | |||
| d7e61c3aba | |||
| c23c51eb78 | |||
| 270b2bb826 | |||
| 0643116e9b | |||
| 03ea055299 | |||
| da12f94be4 | |||
| 64d196c347 | |||
| c8d3e0c912 | |||
| eb463a2958 | |||
| 3282ac67e4 | |||
| 8319891c96 | |||
| 24d97d17ba | |||
| 7425622d93 | |||
| 5050de3a17 | |||
| b1111912f7 | |||
| c1035d97e8 | |||
| b322d0207b | |||
| d64932dad7 | |||
| a3dc79121e | |||
| 9627f58541 | |||
| 1118b8b782 | |||
| 36626d43a1 | |||
| af9a87f8bd | |||
| 056de09645 | |||
| f5c394c96d | |||
| 3824154c15 | |||
| 586c8a550a | |||
| d57effe97c | |||
| 473257f65e | |||
| c1938f78c2 | |||
| e97171d953 | |||
| c6e9fe6513 | |||
| 765a11f135 | |||
| 491bb04877 | |||
| fbbcbb4af1 | |||
| ce133cd6f2 | |||
| dc4c30d791 | |||
| e752b4071d | |||
| 685b4e77eb | |||
| 1a35def375 | |||
| 76d55e72df | |||
| 8127ee7e56 | |||
| efecf7ed82 | |||
| ac46548c4d | |||
| 40384dd442 | |||
| 05b4124761 | |||
| e1e10dca50 | |||
| 0e96465d74 | |||
| 88e9dabaaa | |||
| d65ab0e35d | |||
| f55559e9a3 | |||
| 4ea1e4460a | |||
| b16e69ee86 | |||
| 6b8d71c0b0 | |||
| cb762c97a8 | |||
| 77139c7256 | |||
| 4cf43bc105 | |||
| 588b8ff209 | |||
| 62a8301938 | |||
| ce4e48cbd7 | |||
| 067d90474b | |||
| e0e69fb164 | |||
| 365610d918 | |||
| fdece944f4 | |||
| d7952dab04 | |||
| bec599f325 | |||
| affcc03c61 | |||
| db18c71857 | |||
| dcc223949a | |||
| 6a6d384d88 | |||
| cd57667444 | |||
| 3900db14d3 | |||
| 1fa94cbfad | |||
| 793233e782 | |||
| 94012e5dff | |||
| d440a9fd6a | |||
| 928c6cf5b3 | |||
| 23a25d420c | |||
| dc779a3fc5 | |||
| 876badbeea | |||
| 8563bdde74 | |||
| 803c9699ef | |||
| c254dc5144 | |||
| d22b475539 | |||
| 142205f060 | |||
| 02d997897c | |||
| 39979ff8a3 | |||
| 441b8c5bb7 | |||
| d456ec2786 | |||
| a729ce1512 | |||
| 3949896d88 | |||
| 14e5e11344 | |||
| c23f31216a | |||
| cd04533eea | |||
| 6701551289 | |||
| 1a4833f873 | |||
| 3a7639f690 | |||
| 63c55f08dc | |||
| 98e79f144c | |||
| 3b9236a7ce | |||
| ac30a971c5 | |||
| 9ee6eaade9 | |||
| 8c32fed911 | |||
| f36a5eae6d | |||
| b7bdaac163 | |||
| 162a1b7971 | |||
| 97da73baf3 | |||
| b6e3559aba | |||
| 39a13e3610 | |||
| 7aa89f16c9 | |||
| 88726bed86 | |||
| a35b35c062 | |||
| 951afaa568 | |||
| 5e8979876f | |||
| eb0ef8c696 | |||
| 066b6c13c6 | |||
| 014ad668a5 | |||
| 62c59c634c | |||
| f3f2d614b1 | |||
| 7fd70c1c86 | |||
| 46a3974b79 | |||
| f851cde1f4 | |||
| 0f772fd3cf | |||
| dd0d2e9f55 | |||
| 022c506eda | |||
| dd8577354b | |||
| 3e7a76574b | |||
| 0ef2a2e4b6 | |||
| 8e8046541e | |||
| 2d6f9bab8b | |||
| 11e3cf4f19 | |||
| 37a3fdb606 | |||
| 9983fe7d66 | |||
| 731cf8e4ed | |||
| c3f2e606dd | |||
| dbb62069ef | |||
| b08ad8785e | |||
| ff04eb8d5e | |||
| 9a7503cde2 | |||
| 7d4e7ce2c0 | |||
| 565bb4cd6b | |||
| be592a04d0 | |||
| ae4ac392c6 | |||
| f6b6c0a41e | |||
| 83e4a8f4ed | |||
| 70ef09f451 | |||
| b91b320006 | |||
| d139fffa96 | |||
| 845fc0794e | |||
| ac6c885878 | |||
| b2b5111c50 | |||
| e11629a161 | |||
| ff2fb2b2ba | |||
| b9a9c0e717 | |||
| c16e6d19ae | |||
| 0e98620939 | |||
| e32f51f58a | |||
| 1ec12a547e | |||
| baedced83f | |||
| 174decf8da | |||
| 0700f12896 | |||
| 3dc848a106 | |||
| c17612a233 | |||
| 7313d151f8 | |||
| 97dc9fbccf | |||
| 9a87e4af73 | |||
| 4ccb4243f7 | |||
| eb25ca7af5 | |||
| 872d5178e6 | |||
| d11501b2c6 | |||
| 7e05804bcf | |||
| a73b72a07b | |||
| 8ec4bd4279 | |||
| e362456895 | |||
| 8cd7de25ad | |||
| 99ea7866c5 | |||
| 3194b4cd87 | |||
| 149f52b33c | |||
| 575ec9d00b | |||
| 40e999fcae | |||
| ac57b2b867 | |||
| 3cafa87eda | |||
| dee4ca3559 | |||
| 772c7b3217 | |||
| c0dd58a94e | |||
| 91e116969a | |||
| 1f37e32f91 | |||
| 221061ea30 | |||
| 1e8e45431d | |||
| 381a81e4bb | |||
| be28b9899e | |||
| 37ca139195 | |||
| 6b02779e0f | |||
| ff6d95dc4d | |||
| e611d7a8fd | |||
| 67f6cd3c56 |
@@ -1,4 +1,12 @@
|
|||||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
ARG VARIANT=20
|
||||||
&& apt-get install ffmpeg gnupg2 -y
|
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||||
|
|
||||||
|
# Setup the node environment
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
# Install additional OS packages.
|
||||||
|
RUN apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||||
|
curl tzdata ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Using port 3333 is important when running the client web app separately
|
||||||
|
const Path = require('path')
|
||||||
|
module.exports.config = {
|
||||||
|
Port: 3333,
|
||||||
|
ConfigPath: Path.resolve('config'),
|
||||||
|
MetadataPath: Path.resolve('metadata'),
|
||||||
|
FFmpegPath: '/usr/bin/ffmpeg',
|
||||||
|
FFProbePath: '/usr/bin/ffprobe',
|
||||||
|
SkipBinariesCheck: false
|
||||||
|
}
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||||
{
|
{
|
||||||
"build": { "dockerfile": "Dockerfile" },
|
"name": "Audiobookshelf",
|
||||||
"mounts": [
|
"build": {
|
||||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
"dockerfile": "Dockerfile",
|
||||||
],
|
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||||
"features": {
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
"fish": "latest"
|
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
|
"args": {
|
||||||
|
"VARIANT": "20"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extensions": [
|
"mounts": [
|
||||||
"eamodio.gitlens"
|
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||||
]
|
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||||
|
],
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
"forwardPorts": [
|
||||||
|
3000,
|
||||||
|
3333
|
||||||
|
],
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"octref.vetur"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Mark the working directory as safe for use with git
|
||||||
|
git config --global --add safe.directory $PWD
|
||||||
|
|
||||||
|
# If there is no dev.js file, create it
|
||||||
|
if [ ! -f dev.js ]; then
|
||||||
|
cp .devcontainer/dev.js .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update permissions for node_modules folders
|
||||||
|
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
||||||
|
if [ -d node_modules ]; then
|
||||||
|
sudo chown $(id -u):$(id -g) node_modules
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d client/node_modules ]; then
|
||||||
|
sudo chown $(id -u):$(id -g) client/node_modules
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages for the server
|
||||||
|
if [ -f package.json ]; then
|
||||||
|
npm ci
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages and build the client
|
||||||
|
if [ -f client/package.json ]; then
|
||||||
|
(cd client; npm ci; npm run generate)
|
||||||
|
fi
|
||||||
+2
-1
@@ -12,4 +12,5 @@ dev.js
|
|||||||
test/
|
test/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
/deploy/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Declare files that will always have CRLF line endings on checkout.
|
||||||
|
.devcontainer/post-create.sh text eol=lf
|
||||||
@@ -1,40 +1,50 @@
|
|||||||
name: 🐞 Bug Report
|
name: 🐞 Bug Report
|
||||||
description: File a bug/issue
|
description: File a bug/issue and help us improve Audiobookshelf
|
||||||
title: "[Bug]: "
|
title: '[Bug]: '
|
||||||
labels: ["bug", "triage"]
|
labels: ['bug', 'triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Please first search for your issue and check the [docs](https://audiobookshelf.org/docs)."
|
value: 'Thank you for filing a bug report! 🐛'
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
value: 'Please first search for your issue and check the [docs](https://audiobookshelf.org/docs).'
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
value: 'Report issues with the mobile app [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose).'
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe the issue
|
label: What happened?
|
||||||
description: What happened & what did you expect to happen
|
placeholder: Tell us what you see!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-was-expected
|
||||||
|
attributes:
|
||||||
|
label: What did you expect to happen?
|
||||||
|
placeholder: Tell us what you expected to see! Be as descriptive as you can and include screenshots if applicable.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps-to-reproduce
|
id: steps-to-reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce the issue
|
label: Steps to reproduce the issue
|
||||||
value: "1. "
|
value: '1. '
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## Install Environment'
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Audiobookshelf version
|
label: Audiobookshelf version
|
||||||
description: Do not put 'Latest version', please put the actual version here
|
description: Do not put 'Latest version', please put the actual version here
|
||||||
placeholder: "e.g. v1.6.60"
|
placeholder: 'e.g. v1.6.60'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@@ -44,7 +54,45 @@ body:
|
|||||||
options:
|
options:
|
||||||
- Docker
|
- Docker
|
||||||
- Debian/PPA
|
- Debian/PPA
|
||||||
|
- Windows Tray App
|
||||||
- Built from source
|
- Built from source
|
||||||
- Other
|
- Other (list in "Additional Notes" box)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: server-os
|
||||||
|
attributes:
|
||||||
|
label: What OS is your Audiobookshelf server hosted from?
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
- Other (list in "Additional Notes" box)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: desktop-browsers
|
||||||
|
attributes:
|
||||||
|
label: If the issue is being seen in the UI, what browsers are you seeing the problem on?
|
||||||
|
options:
|
||||||
|
- Chrome
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
- Edge
|
||||||
|
- Firefox for Android
|
||||||
|
- Chrome for Android
|
||||||
|
- Safari on iOS
|
||||||
|
- Other (list in "Additional Notes" box)
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: Please include any relevant logs here. This field is automatically formatted into code, so you do not need to include any backticks.
|
||||||
|
placeholder: Paste logs here
|
||||||
|
render: shell
|
||||||
|
- type: textarea
|
||||||
|
id: additional-notes
|
||||||
|
attributes:
|
||||||
|
label: Additional Notes
|
||||||
|
description: Anything else you want to add?
|
||||||
|
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/HQgCbd6E75
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
@@ -1,17 +1,63 @@
|
|||||||
name: 🚀 Feature Request
|
name: 🚀 Feature Request
|
||||||
description: Request a feature/enhancement
|
description: Request a feature/enhancement
|
||||||
title: "[Enhancement]: "
|
title: '[Enhancement]: '
|
||||||
labels: ["enhancement"]
|
labels: ['enhancement']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Please first search in both issues & discussions for your enhancement."
|
value: '#### *Mobile app features should be [requested here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)*.'
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Mobile app features should be requested [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
value: '## Web/Server Feature Request Description'
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: 'Please first search in both issues & discussions for your enhancement.'
|
||||||
|
- type: dropdown
|
||||||
|
id: enhancment-type
|
||||||
|
attributes:
|
||||||
|
label: Type of Enhancement
|
||||||
|
options:
|
||||||
|
- Server Backend
|
||||||
|
- Web Interface/Frontend
|
||||||
|
- Documentation
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: describe
|
id: describe
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe the feature/enhancement
|
label: Describe the Feature/Enhancement
|
||||||
|
description: Please help us understand what you want.
|
||||||
|
placeholder: What is your vision?
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: the-why
|
||||||
|
attributes:
|
||||||
|
label: Why would this be helpful?
|
||||||
|
description: Please help us understand why this would enhance your experience.
|
||||||
|
placeholder: Explain the "why" or "use case".
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: image
|
||||||
|
attributes:
|
||||||
|
label: Future Implementation (Screenshot)
|
||||||
|
description: Please help us visualize by including a doodle or screenshot.
|
||||||
|
placeholder: How could this look?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## Web/Server Current Implementation'
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Audiobookshelf Server Version
|
||||||
|
description: Do not put 'Latest version', please put your current version number here
|
||||||
|
placeholder: 'e.g. v1.6.60'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-image
|
||||||
|
attributes:
|
||||||
|
label: Current Implementation (Screenshot)
|
||||||
|
description: What page were you looking at when you thought of this enhancement?
|
||||||
|
placeholder: If an image is not applicable, please explain why.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Add issue comments by label
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- labeled
|
||||||
|
jobs:
|
||||||
|
help-wanted:
|
||||||
|
if: github.event.label.name == 'help wanted'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Help wanted comment
|
||||||
|
run: gh issue comment "$NUMBER" --body "$BODY"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
BODY: >
|
||||||
|
This issue is not able to be completed due to limited bandwidth or access to the required test hardware.
|
||||||
|
|
||||||
|
This issue is available for anyone to work on.
|
||||||
|
|
||||||
|
|
||||||
|
config-issue:
|
||||||
|
if: github.event.label.name == 'config-issue'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Config issue comment
|
||||||
|
run: gh issue close "$NUMBER" --reason "not planned" --comment "$BODY"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
BODY: >
|
||||||
|
After reviewing this issue, this appears to be a problem with your setup and not Audiobookshelf. This issue is being closed to keep the issue tracker focused on Audiobookshelf itself. Please reach out on the Audiobookshelf Discord for community support.
|
||||||
|
|
||||||
|
Some common search terms to help you find the solution to your problem:
|
||||||
|
- Reverse proxy
|
||||||
|
- Enabling websockets
|
||||||
|
- SSL (https vs http)
|
||||||
|
- Configuring a static IP
|
||||||
|
- `localhost` versus IP address
|
||||||
|
- hairpin NAT
|
||||||
|
- VPN
|
||||||
|
- firewall ports
|
||||||
|
- public versus private network
|
||||||
|
- bridge versus host mode
|
||||||
|
- Docker networking
|
||||||
|
- DNS (such as EAI_AGAIN errors)
|
||||||
|
|
||||||
|
After you have followed these steps, please post the solution or steps you followed to fix the problem to help others in the future, or show that it is a problem with Audiobookshelf so we can reopen the issue.
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Close fixed issues on release.
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close issues marked as fixed upon a release.
|
||||||
|
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||||
|
with:
|
||||||
|
label: 'awaiting release'
|
||||||
|
removeLabel: true
|
||||||
|
applyToAll: true
|
||||||
|
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 'master' ]
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ 'master' ]
|
||||||
|
schedule:
|
||||||
|
- cron: '16 5 * * 4'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||||
|
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||||
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
||||||
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
|
|
||||||
|
# - run: |
|
||||||
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Verify all i18n files are alphabetized
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- client/strings/** # Should only check if any strings changed
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- client/strings/** # Should only check if any strings changed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up node to run the javascript
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
# The only argument is the `directory`, which is where the i18n files are
|
||||||
|
# stored.
|
||||||
|
- name: Run Update JSON Files action
|
||||||
|
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||||
|
with:
|
||||||
|
directory: 'client/strings/' # Adjust the directory path as needed
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Integration Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build and test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: setup nade
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: install pkg (using yao-pkg fork for targetting node20)
|
||||||
|
run: npm install -g @yao-pkg/pkg
|
||||||
|
|
||||||
|
- name: get client dependencies
|
||||||
|
working-directory: client
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: build client
|
||||||
|
working-directory: client
|
||||||
|
run: npm run generate
|
||||||
|
|
||||||
|
- name: get server dependencies
|
||||||
|
run: npm ci --only=production
|
||||||
|
|
||||||
|
- name: build binary
|
||||||
|
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
||||||
|
|
||||||
|
- name: run audiobookshelf
|
||||||
|
run: |
|
||||||
|
./audiobookshelf &
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
- name: test if server is available
|
||||||
|
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: API linting
|
||||||
|
|
||||||
|
# Run on pull requests or pushes when there is a change to any OpenAPI files in docs/
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
|
# This action only needs read permissions
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Set up node to run the javascript
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
# Install Redocly CLI
|
||||||
|
- name: Install Redocly CLI
|
||||||
|
run: npm install -g @redocly/cli@latest
|
||||||
|
# Perform linting for exploded spec
|
||||||
|
- name: Run linting for exploded spec
|
||||||
|
run: redocly lint docs/root.yaml --format=github-actions
|
||||||
|
# Perform linting for bundled spec
|
||||||
|
- name: Run linting for bundled spec
|
||||||
|
run: redocly lint docs/openapi.json --format=github-actions
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Dispatch an abs-windows event
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
abs-windows-dispatch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Send a remote repository dispatch event
|
||||||
|
uses: peter-evans/repository-dispatch@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.ABS_WINDOWS_PAT }}
|
||||||
|
repository: mikiher/audiobookshelf-windows
|
||||||
|
event-type: build-windows
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Run Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: 'Branch/Tag/SHA to test'
|
||||||
|
required: true
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-unit-tests:
|
||||||
|
name: Run Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout (push/pull request)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
if: github.event_name != 'workflow_dispatch'
|
||||||
|
|
||||||
|
- name: Checkout (workflow_dispatch)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
+10
-4
@@ -1,17 +1,23 @@
|
|||||||
.env
|
.env
|
||||||
dev.js
|
/dev.js
|
||||||
node_modules/
|
**/node_modules/
|
||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
/podcasts/
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
test/
|
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
library/
|
/deploy/
|
||||||
|
/coverage/
|
||||||
|
/.nyc_output/
|
||||||
|
/ffmpeg*
|
||||||
|
/ffprobe*
|
||||||
|
/unicode*
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
|
.idea/*
|
||||||
|
tailwind.compiled.css
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.html"],
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"octref.vetur"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug client (nuxt)",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}/client",
|
||||||
|
"skipFiles": [
|
||||||
|
"${workspaceFolder}/<node_internals>/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Debug server and client (nuxt)",
|
||||||
|
"configurations": [
|
||||||
|
"Debug server",
|
||||||
|
"Debug client (nuxt)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"vetur.format.defaultFormatterOptions": {
|
||||||
|
"prettier": {
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none"
|
||||||
|
},
|
||||||
|
"prettyhtml": {
|
||||||
|
"printWidth": 400,
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.detectIndentation": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"javascript.format.semicolons": "remove",
|
||||||
|
"[javascript][json][jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "octref.vetur"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"path": "client",
|
||||||
|
"type": "npm",
|
||||||
|
"script": "generate",
|
||||||
|
"detail": "nuxt generate",
|
||||||
|
"label": "Build client",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dependsOn": [
|
||||||
|
"Build client"
|
||||||
|
],
|
||||||
|
"type": "npm",
|
||||||
|
"script": "dev",
|
||||||
|
"detail": "nodemon --watch server index.js",
|
||||||
|
"label": "Run server",
|
||||||
|
"group": {
|
||||||
|
"kind": "test",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "client",
|
||||||
|
"type": "npm",
|
||||||
|
"script": "dev",
|
||||||
|
"detail": "nuxt",
|
||||||
|
"label": "Run Live-reload client",
|
||||||
|
"group": {
|
||||||
|
"kind": "test",
|
||||||
|
"isDefault": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+15
-9
@@ -1,18 +1,25 @@
|
|||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:16-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm ci && npm cache clean --force
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:16-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
curl \
|
curl \
|
||||||
tzdata \
|
tzdata \
|
||||||
ffmpeg
|
ffmpeg \
|
||||||
|
make \
|
||||||
|
gcompat \
|
||||||
|
python3 \
|
||||||
|
g++ \
|
||||||
|
tini
|
||||||
|
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js package* /
|
COPY index.js package* /
|
||||||
@@ -20,10 +27,9 @@ COPY server server
|
|||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK \
|
|
||||||
--interval=30s \
|
ENTRYPOINT ["tini", "--"]
|
||||||
--timeout=3s \
|
CMD ["node", "index.js"]
|
||||||
--start-period=10s \
|
|
||||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
|
|||||||
@@ -2,14 +2,11 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
|
||||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=13378
|
||||||
DEFAULT_HOST="0.0.0.0"
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
add_user() {
|
add_user() {
|
||||||
: "${1:?'User was not defined'}"
|
: "${1:?'User was not defined'}"
|
||||||
declare -r user="$1"
|
declare -r user="$1"
|
||||||
@@ -48,29 +45,11 @@ add_group() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_ffmpeg() {
|
|
||||||
echo "Starting FFMPEG Install"
|
|
||||||
|
|
||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
|
||||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
|
||||||
mkdir "$FFMPEG_INSTALL_DIR"
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
|
||||||
cd "$FFMPEG_INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
$WGET
|
|
||||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg... hopefully"
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
@@ -83,11 +62,9 @@ setup_config() {
|
|||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
PORT=$DEFAULT_PORT
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
HOST=$DEFAULT_HOST"
|
||||||
PORT=$DEFAULT_PORT
|
|
||||||
HOST=$DEFAULT_HOST"
|
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
@@ -102,5 +79,3 @@ add_group 'audiobookshelf' ''
|
|||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
setup_config
|
||||||
|
|
||||||
install_ffmpeg
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
|||||||
ExecReload=/bin/kill -HUP $MAINPID
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
Restart=always
|
Restart=always
|
||||||
User=audiobookshelf
|
User=audiobookshelf
|
||||||
PermissionsStartOnly=true
|
Group=audiobookshelf
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
+2
-3
@@ -48,11 +48,10 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# Package debian
|
||||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||||
|
|
||||||
fakeroot dpkg-deb --build dist/debian
|
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||||
|
|
||||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||||
chmod +x "dist/$OUTPUT_FILE"
|
|
||||||
|
|
||||||
echo "Finished! Filename: $OUTPUT_FILE"
|
echo "Finished! Filename: $OUTPUT_FILE"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'absicons';
|
||||||
|
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
|
||||||
|
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
|
||||||
|
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
|
||||||
|
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
|
||||||
|
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abs-icons {
|
||||||
|
/* use !important to prevent issues with browser extensions that change fonts */
|
||||||
|
font-family: 'absicons' !important;
|
||||||
|
speak: never;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-variant: normal;
|
||||||
|
text-transform: none;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
/* Better Font Rendering =========== */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-books-1:before {
|
||||||
|
content: "\e905";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-microphone-1:before {
|
||||||
|
content: "\e902";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-radio:before {
|
||||||
|
content: "\e903";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-podcast:before {
|
||||||
|
content: "\e904";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-audiobookshelf:before {
|
||||||
|
content: "\e900";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-database:before {
|
||||||
|
content: "\e906";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-microphone-2:before {
|
||||||
|
content: "\e901";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-headphones:before {
|
||||||
|
content: "\e910";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-music:before {
|
||||||
|
content: "\e911";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-video:before {
|
||||||
|
content: "\e914";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-microphone-3:before {
|
||||||
|
content: "\e91e";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-book-1:before {
|
||||||
|
content: "\e91f";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-books-2:before {
|
||||||
|
content: "\e920";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-file-picture:before {
|
||||||
|
content: "\e927";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-database-1:before {
|
||||||
|
content: "\e964";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-rocket:before {
|
||||||
|
content: "\e9a5";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-power:before {
|
||||||
|
content: "\e9b5";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-star:before {
|
||||||
|
content: "\e9d9";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart:before {
|
||||||
|
content: "\e9da";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-rss:before {
|
||||||
|
content: "\ea9b";
|
||||||
|
}
|
||||||
+27
-19
@@ -2,6 +2,7 @@
|
|||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@import './draggable.css';
|
@import './draggable.css';
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
|
@import './absicons.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||||
@@ -22,11 +23,14 @@
|
|||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookshelf-row {
|
.bookshelf-row {
|
||||||
/* Sidebar width + scrollbar width */
|
width: calc(100vw - (100vw - 100%));
|
||||||
width: calc(100vw - 88px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -107,7 +111,7 @@ input[type=number] {
|
|||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover {
|
.tracksTable tr:hover:not(:has(th)) {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,22 +216,6 @@ Bookshelf Label
|
|||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.episode-subtitle {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
line-height: 16px;
|
|
||||||
/* fallback */
|
|
||||||
max-height: 32px;
|
|
||||||
/* fallback */
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
/* number of lines to show */
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
padding-top: 104px;
|
padding-top: 104px;
|
||||||
@@ -239,4 +227,24 @@ Bookshelf Label
|
|||||||
|
|
||||||
.no-bars .Vue-Toastification__container.top-right {
|
.no-bars .Vue-Toastification__container.top-right {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abs-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abs-btn:hover:not(:disabled)::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.abs-btn:disabled::before {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
+36
-79
@@ -1,19 +1,12 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Symbols Rounded';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(~static/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
.material-symbols {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Symbols Rounded';
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-icons {
|
|
||||||
font-family: 'Material Icons';
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -24,48 +17,12 @@
|
|||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
.material-symbols.fill {
|
||||||
font-size: 1.5rem;
|
font-variation-settings:
|
||||||
}
|
'FILL' 1
|
||||||
|
|
||||||
.material-icons-outlined {
|
|
||||||
font-family: 'Material Icons Outlined';
|
|
||||||
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-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* cyrillic-ext */
|
/* cyrillic-ext */
|
||||||
@@ -74,7 +31,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +41,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +51,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +61,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +71,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +81,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +91,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +101,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +111,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +121,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +131,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +141,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +151,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +161,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +171,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +181,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +191,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +201,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +211,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +221,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +231,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +241,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +251,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +261,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +271,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +281,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +291,6 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -20,42 +20,67 @@
|
|||||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-enter-to, .slide-leave {
|
.slide-enter-to,
|
||||||
|
.slide-leave {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-enter, .slide-leave-to {
|
.slide-enter,
|
||||||
|
.slide-leave-to {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.menu-enter, .menu-leave-active {
|
.menu-enter,
|
||||||
|
.menu-leave-active {
|
||||||
transform: translateY(-15px);
|
transform: translateY(-15px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-enter-active {
|
.menu-enter-active {
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-leave-active {
|
.menu-leave-active {
|
||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-enter,
|
.menu-enter,
|
||||||
.menu-leave-active {
|
.menu-leave-active {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menux-enter, .menux-leave-active {
|
.menux-enter,
|
||||||
|
.menux-leave-active {
|
||||||
transform: translateX(15px);
|
transform: translateX(15px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menux-enter-active {
|
.menux-enter-active {
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menux-leave-active {
|
.menux-leave-active {
|
||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menux-enter,
|
.menux-enter,
|
||||||
.menux-leave-active {
|
.menux-leave-active {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.list-complete-item {
|
||||||
|
transition: all 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-complete-enter-from,
|
||||||
|
.list-complete-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-complete-leave-active {
|
||||||
|
position: absolute;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
@@ -15,56 +15,68 @@
|
|||||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-2xl md:text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
|
||||||
|
|
||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
<span class="material-symbols text-2xl text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
<widgets-notification-widget class="hidden md:block" />
|
||||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
|
||||||
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
|
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl" aria-label="User Stats" role="button"></span>
|
||||||
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl" aria-label="Upload Media" role="button"></span>
|
||||||
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
||||||
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md: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">
|
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||||
<span class="items-center hidden md:flex">
|
<span class="items-center hidden md:flex">
|
||||||
<span class="block truncate">{{ username }}</span>
|
<span class="block truncate">{{ username }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
||||||
<span class="material-icons text-gray-100">person</span>
|
<span class="material-symbols text-xl text-gray-100"></span>
|
||||||
</span>
|
</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||||
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||||
|
<span class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
|
{{ $strings.ButtonPlay }}
|
||||||
|
</ui-btn>
|
||||||
|
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" text="Add to Collection" direction="bottom">
|
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
<template v-if="userCanUpdate">
|
||||||
<ui-tooltip text="Edit" direction="bottom">
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
|
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip text="Deselect All" direction="bottom">
|
|
||||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||||
|
|
||||||
|
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,9 +87,7 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processingBatchDelete: false,
|
totalEntities: 0
|
||||||
totalEntities: 0,
|
|
||||||
isAllSelected: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -93,6 +103,9 @@ export default {
|
|||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.libraryMediaType === 'podcast'
|
return this.libraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.libraryMediaType === 'book'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -105,11 +118,14 @@ export default {
|
|||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numLibraryItemsSelected() {
|
numMediaItemsSelected() {
|
||||||
return this.selectedLibraryItems.length
|
return this.selectedMediaItems.length
|
||||||
},
|
},
|
||||||
selectedLibraryItems() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.selectedLibraryItems
|
return this.$store.state.globals.selectedMediaItems
|
||||||
|
},
|
||||||
|
selectedMediaItemsArePlayable() {
|
||||||
|
return !this.selectedMediaItems.some((i) => !i.hasTracks)
|
||||||
},
|
},
|
||||||
userMediaProgress() {
|
userMediaProgress() {
|
||||||
return this.$store.state.user.user.mediaProgress || []
|
return this.$store.state.user.user.mediaProgress || []
|
||||||
@@ -125,17 +141,14 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedIsFinished() {
|
selectedIsFinished() {
|
||||||
// Find an item that is not finished, if none then all items finished
|
// Find an item that is not finished, if none then all items finished
|
||||||
return !this.selectedLibraryItems.find((libraryItemId) => {
|
return !this.selectedMediaItems.find((item) => {
|
||||||
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
|
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
|
||||||
return !itemProgress || !itemProgress.isFinished
|
return !itemProgress || !itemProgress.isFinished
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.$store.state.processingBatch
|
return this.$store.state.processingBatch
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
isChromecastEnabled() {
|
isChromecastEnabled() {
|
||||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||||
},
|
},
|
||||||
@@ -144,21 +157,145 @@ export default {
|
|||||||
},
|
},
|
||||||
isHttps() {
|
isHttps() {
|
||||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||||
|
},
|
||||||
|
contextMenuItems() {
|
||||||
|
if (!this.userIsAdminOrUp) return []
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonQuickMatch,
|
||||||
|
action: 'quick-match'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||||
|
options.push({
|
||||||
|
text: this.$strings.ButtonQuickEmbedMetadata,
|
||||||
|
action: 'quick-embed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
text: this.$strings.ButtonReScan,
|
||||||
|
action: 'rescan'
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
requestBatchQuickEmbed() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmQuickEmbed,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/tools/batch/embed-metadata`, {
|
||||||
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata embed started')
|
||||||
|
this.cancelSelectionMode()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata embed failed', error)
|
||||||
|
const errorMsg = error.response.data || 'Failed to embed metadata'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
contextMenuAction({ action }) {
|
||||||
|
if (action === 'quick-embed') {
|
||||||
|
this.requestBatchQuickEmbed()
|
||||||
|
} else if (action === 'quick-match') {
|
||||||
|
this.batchAutoMatchClick()
|
||||||
|
} else if (action === 'rescan') {
|
||||||
|
this.batchRescan()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async batchRescan() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/batch/scan`, {
|
||||||
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('Batch Re-Scan started')
|
||||||
|
this.cancelSelectionMode()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Batch Re-Scan failed', error)
|
||||||
|
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
async playSelectedItems() {
|
||||||
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
|
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
|
||||||
|
const libraryItems = await this.$axios
|
||||||
|
.$post(`/api/items/batch/get`, { libraryItemIds })
|
||||||
|
.then((res) => res.libraryItems)
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response.data || 'Failed to get items'
|
||||||
|
console.error(errorMsg, error)
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!libraryItems.length) {
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueItems = []
|
||||||
|
libraryItems.forEach((item) => {
|
||||||
|
let subtitle = ''
|
||||||
|
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
||||||
|
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
|
||||||
|
queueItems.push({
|
||||||
|
libraryItemId: item.id,
|
||||||
|
libraryId: item.libraryId,
|
||||||
|
episodeId: null,
|
||||||
|
title: item.media.metadata.title,
|
||||||
|
subtitle,
|
||||||
|
caption: '',
|
||||||
|
duration: item.media.duration || null,
|
||||||
|
coverPath: item.media.coverPath || null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: queueItems[0].libraryItemId,
|
||||||
|
queueItems
|
||||||
|
})
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
|
},
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatch) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
this.isAllSelected = false
|
|
||||||
},
|
},
|
||||||
toggleBatchRead() {
|
toggleBatchRead() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
var newIsFinished = !this.selectedIsFinished
|
const newIsFinished = !this.selectedIsFinished
|
||||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
|
||||||
return {
|
return {
|
||||||
id: lid,
|
libraryItemId: item.id,
|
||||||
isFinished: newIsFinished
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -166,50 +303,63 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch update success!')
|
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error('Batch update failed')
|
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
||||||
console.error('Failed to batch update read/not read', error)
|
console.error('Failed to batch update read/not read', error)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
|
const payload = {
|
||||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]),
|
||||||
if (confirm(confirmMsg)) {
|
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||||
this.processingBatchDelete = true
|
yesButtonText: this.$strings.ButtonDelete,
|
||||||
this.$store.commit('setProcessingBatch', true)
|
yesButtonColor: 'error',
|
||||||
this.$axios
|
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||||
.$post(`/api/items/batch/delete`, {
|
callback: (confirmed, hardDelete) => {
|
||||||
libraryItemIds: this.selectedLibraryItems
|
if (confirmed) {
|
||||||
})
|
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Batch delete success!')
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.processingBatchDelete = false
|
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$axios
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then(() => {
|
||||||
this.$toast.error('Batch delete failed')
|
this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
|
||||||
console.error('Failed to batch delete', error)
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.processingBatchDelete = false
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
|
console.error('Batch delete failed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastBatchDeleteFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
}
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
batchEditClick() {
|
batchEditClick() {
|
||||||
this.$router.push('/batch')
|
this.$router.push('/batch')
|
||||||
},
|
},
|
||||||
batchAddToCollectionClick() {
|
batchAddToCollectionClick() {
|
||||||
this.$store.commit('globals/setShowBatchUserCollectionsModal', true)
|
this.$store.commit('globals/setShowBatchCollectionsModal', true)
|
||||||
},
|
},
|
||||||
setBookshelfTotalEntities(totalEntities) {
|
setBookshelfTotalEntities(totalEntities) {
|
||||||
this.totalEntities = totalEntities
|
this.totalEntities = totalEntities
|
||||||
|
},
|
||||||
|
batchAutoMatchClick() {
|
||||||
|
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,39 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div v-if="userIsAdminOrUp" class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||||
<p class="text-center text-xl font-book py-4">No results for query</p>
|
<p class="text-center text-xl py-4">{{ $strings.MessageBookshelfNoResultsForQuery }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in supportedShelves">
|
||||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
|
||||||
</widgets-episode-slider>
|
|
||||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
|
||||||
</widgets-series-slider>
|
|
||||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
|
||||||
</widgets-authors-slider>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Regular bookshelf view -->
|
<!-- Regular bookshelf view -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in supportedShelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,24 +45,29 @@ export default {
|
|||||||
keywordFilterTimeout: null,
|
keywordFilterTimeout: null,
|
||||||
scannerParseSubtitle: false,
|
scannerParseSubtitle: false,
|
||||||
wrapperClientWidth: 0,
|
wrapperClientWidth: 0,
|
||||||
shelves: []
|
shelves: [],
|
||||||
|
lastItemIndexSelected: -1,
|
||||||
|
tempIsScanning: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
supportedShelves() {
|
||||||
|
return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators'].includes(shelf.type))
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.TITLES
|
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.DETAIL
|
||||||
},
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
@@ -85,11 +81,81 @@ export default {
|
|||||||
return this.coverAspectRatio == 1
|
return this.coverAspectRatio == 1
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
return this.bookCoverWidth / baseSize
|
},
|
||||||
|
selectedMediaItems() {
|
||||||
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
isScanningLibrary() {
|
||||||
|
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
selectEntity({ entity, shiftKey }, shelfIndex) {
|
||||||
|
const shelf = this.shelves[shelfIndex]
|
||||||
|
const entityShelfIndex = shelf.entities.findIndex((ent) => ent.id === entity.id)
|
||||||
|
const indexOf = shelf.shelfStartIndex + entityShelfIndex
|
||||||
|
|
||||||
|
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||||
|
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||||
|
this.lastItemIndexSelected = indexOf
|
||||||
|
} else {
|
||||||
|
this.lastItemIndexSelected = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||||
|
let loopStart = indexOf
|
||||||
|
let loopEnd = lastLastItemIndexSelected
|
||||||
|
if (indexOf > lastLastItemIndexSelected) {
|
||||||
|
loopStart = lastLastItemIndexSelected
|
||||||
|
loopEnd = indexOf
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenedEntitiesArray = []
|
||||||
|
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
|
||||||
|
|
||||||
|
let isSelecting = false
|
||||||
|
// If any items in this range is not selected then select all otherwise unselect all
|
||||||
|
for (let i = loopStart; i <= loopEnd; i++) {
|
||||||
|
const thisEntity = flattenedEntitiesArray[i]
|
||||||
|
if (thisEntity) {
|
||||||
|
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||||
|
isSelecting = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSelecting) this.lastItemIndexSelected = indexOf
|
||||||
|
|
||||||
|
for (let i = loopStart; i <= loopEnd; i++) {
|
||||||
|
const thisEntity = flattenedEntitiesArray[i]
|
||||||
|
if (thisEntity) {
|
||||||
|
const mediaItem = {
|
||||||
|
id: thisEntity.id,
|
||||||
|
mediaType: thisEntity.mediaType,
|
||||||
|
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||||
|
} else {
|
||||||
|
console.error('Invalid entity index', i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected', entity)
|
||||||
|
})
|
||||||
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||||
|
|
||||||
@@ -101,8 +167,19 @@ export default {
|
|||||||
this.loaded = true
|
this.loaded = true
|
||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
var categories = await this.$axios
|
// Sets the limit for the number of items to be displayed based on the viewport width.
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
const viewportWidth = window.innerWidth
|
||||||
|
let limit
|
||||||
|
if (viewportWidth >= 3240) {
|
||||||
|
limit = 15
|
||||||
|
} else if (viewportWidth >= 2880 && viewportWidth < 3240) {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitQuery = limit ? `&limit=${limit}` : ''
|
||||||
|
|
||||||
|
const categories = await this.$axios
|
||||||
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share${limitQuery}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -110,32 +187,41 @@ export default {
|
|||||||
console.error('Failed to fetch categories', error)
|
console.error('Failed to fetch categories', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let totalEntityCount = 0
|
||||||
|
for (const shelf of categories) {
|
||||||
|
shelf.shelfStartIndex = totalEntityCount
|
||||||
|
totalEntityCount += shelf.entities.length
|
||||||
|
}
|
||||||
this.shelves = categories
|
this.shelves = categories
|
||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
var shelves = []
|
const shelves = []
|
||||||
if (this.results.books && this.results.books.length) {
|
if (this.results.books?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'books',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
|
labelStringKey: 'LabelBooks',
|
||||||
type: 'book',
|
type: 'book',
|
||||||
entities: this.results.books.map((res) => res.libraryItem)
|
entities: this.results.books.map((res) => res.libraryItem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.podcasts && this.results.podcasts.length) {
|
if (this.results.podcasts?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'podcasts',
|
id: 'podcasts',
|
||||||
label: 'Podcasts',
|
label: 'Podcasts',
|
||||||
|
labelStringKey: 'LabelPodcasts',
|
||||||
type: 'podcast',
|
type: 'podcast',
|
||||||
entities: this.results.podcasts.map((res) => res.libraryItem)
|
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series && this.results.series.length) {
|
if (this.results.series?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
|
labelStringKey: 'LabelSeries',
|
||||||
type: 'series',
|
type: 'series',
|
||||||
entities: this.results.series.map((seriesObj) => {
|
entities: this.results.series.map((seriesObj) => {
|
||||||
return {
|
return {
|
||||||
@@ -146,10 +232,11 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags && this.results.tags.length) {
|
if (this.results.tags?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
|
labelStringKey: 'LabelTags',
|
||||||
type: 'tags',
|
type: 'tags',
|
||||||
entities: this.results.tags.map((tagObj) => {
|
entities: this.results.tags.map((tagObj) => {
|
||||||
return {
|
return {
|
||||||
@@ -160,10 +247,11 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors && this.results.authors.length) {
|
if (this.results.authors?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: 'Authors',
|
label: 'Authors',
|
||||||
|
labelStringKey: 'LabelAuthors',
|
||||||
type: 'authors',
|
type: 'authors',
|
||||||
entities: this.results.authors.map((a) => {
|
entities: this.results.authors.map((a) => {
|
||||||
return {
|
return {
|
||||||
@@ -173,19 +261,43 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (this.results.narrators?.length) {
|
||||||
|
shelves.push({
|
||||||
|
id: 'narrators',
|
||||||
|
label: 'Narrators',
|
||||||
|
labelStringKey: 'LabelNarrators',
|
||||||
|
type: 'narrators',
|
||||||
|
entities: this.results.narrators.map((n) => {
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
type: 'narrator'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
this.shelves = shelves
|
this.shelves = shelves
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
|
||||||
scan() {
|
scan() {
|
||||||
|
this.tempIsScanning = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to start scan', error)
|
console.error('Failed to start scan', error)
|
||||||
this.$toast.error('Failed to start scan')
|
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.tempIsScanning = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
userUpdated(user) {
|
||||||
|
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||||
|
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
||||||
|
}
|
||||||
|
if (user.mediaProgress.length) {
|
||||||
|
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||||
|
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
||||||
|
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('libraryItem added', libraryItem)
|
||||||
@@ -234,9 +346,16 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('libraryItems added', libraryItems)
|
console.log('libraryItems added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
|
||||||
if (!this.search) {
|
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
||||||
this.fetchCategories()
|
if (!recentlyAddedShelf) return
|
||||||
|
|
||||||
|
// Add new library item to the recently added shelf
|
||||||
|
for (const libraryItem of libraryItems) {
|
||||||
|
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
|
||||||
|
// Add to front of array
|
||||||
|
recentlyAddedShelf.entities.unshift(libraryItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemsUpdated(items) {
|
libraryItemsUpdated(items) {
|
||||||
@@ -244,6 +363,40 @@ export default {
|
|||||||
this.libraryItemUpdated(li)
|
this.libraryItemUpdated(li)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
episodeAdded(episodeWithLibraryItem) {
|
||||||
|
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||||
|
if (!this.search && isThisLibrary) {
|
||||||
|
this.fetchCategories()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||||
|
// Filter out series books from continue series shelf
|
||||||
|
shelf.entities = shelf.entities.filter((ent) => {
|
||||||
|
if (ent.media.metadata.series && seriesIds.includes(ent.media.metadata.series.id)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
||||||
|
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
||||||
|
if (continueListeningShelf) {
|
||||||
|
if (continueListeningShelf.type === 'book') {
|
||||||
|
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||||
|
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else if (continueListeningShelf.type === 'episode') {
|
||||||
|
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||||
|
if (!ent.recentEpisode) return true // Should always have this here
|
||||||
|
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id && mp.episodeId === ent.recentEpisode.id)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type == 'authors') {
|
if (shelf.type == 'authors') {
|
||||||
@@ -266,10 +419,39 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
shareOpen(mediaItemShare) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'book') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
mediaItemShare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shareClosed(mediaItemShare) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'book') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
mediaItemShare: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
this.$root.socket.on('user_updated', this.userUpdated)
|
||||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
@@ -277,14 +459,16 @@ export default {
|
|||||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||||
|
this.$root.socket.on('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeListeners() {
|
removeListeners() {
|
||||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
this.$root.socket.off('user_updated', this.userUpdated)
|
||||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
@@ -292,6 +476,9 @@ export default {
|
|||||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||||
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll no-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft + 'em' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6e">
|
||||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
<cards-lazy-series-card :key="entity.name" :series-mount="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||||
|
<template v-for="entity in shelf.entities">
|
||||||
|
<cards-group-card :key="entity.name" :group="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||||
|
<template v-for="entity in shelf.entities">
|
||||||
|
<cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="relative">
|
||||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||||
|
|
||||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
|
||||||
<span class="material-icons text-6xl text-white">chevron_left</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||||
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,9 +60,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
sizeMultiplier: Number,
|
continueListeningShelf: Boolean
|
||||||
bookCoverWidth: Number,
|
|
||||||
bookCoverAspectRatio: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,11 +72,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverHeight() {
|
sizeMultiplier() {
|
||||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
},
|
|
||||||
shelfHeight() {
|
|
||||||
return this.bookCoverHeight + 48
|
|
||||||
},
|
},
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
if (window.innerWidth < 768) return 1
|
if (window.innerWidth < 768) return 1
|
||||||
@@ -78,7 +83,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -99,14 +104,14 @@ export default {
|
|||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
updateSelectionMode(val) {
|
updateSelectionMode(val) {
|
||||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||||
this.shelf.entities.forEach((ent) => {
|
this.shelf.entities.forEach((ent) => {
|
||||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||||
if (!component || !component.length) return
|
if (!component || !component.length) return
|
||||||
component = component[0]
|
component = component[0]
|
||||||
component.setSelectionMode(val)
|
component.setSelectionMode(val)
|
||||||
component.selected = selectedLibraryItems.includes(ent.id)
|
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||||
})
|
})
|
||||||
} else if (this.shelf.type === 'episode') {
|
} else if (this.shelf.type === 'episode') {
|
||||||
this.shelf.entities.forEach((ent) => {
|
this.shelf.entities.forEach((ent) => {
|
||||||
@@ -114,15 +119,12 @@ export default {
|
|||||||
if (!component || !component.length) return
|
if (!component || !component.length) return
|
||||||
component = component[0]
|
component = component[0]
|
||||||
component.setSelectionMode(val)
|
component.setSelectionMode(val)
|
||||||
component.selected = selectedLibraryItems.includes(ent.id)
|
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectItem(libraryItem) {
|
selectItem(payload) {
|
||||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
this.$emit('selectEntity', payload)
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$eventBus.$emit('item-selected', libraryItem)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
itemSelectedEvt() {
|
itemSelectedEvt() {
|
||||||
this.updateSelectionMode(this.isSelectionMode)
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
@@ -171,11 +173,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,13 +197,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-shelf-arrow-right {
|
.book-shelf-arrow-right {
|
||||||
height: calc(100% - 24px);
|
height: calc(100% - 1.5em);
|
||||||
background: rgb(48, 48, 48);
|
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%);
|
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 {
|
.book-shelf-arrow-left {
|
||||||
height: calc(100% - 24px);
|
height: calc(100% - 1.5em);
|
||||||
background: rgb(48, 48, 48);
|
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%);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,75 +1,108 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-20 md:h-10 relative">
|
<div class="w-full h-20 md:h-10 relative">
|
||||||
<div class="flex md:hidden h-10 items-center">
|
<div class="flex md:hidden h-10 items-center">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">Home</p>
|
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">Library</p>
|
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">Series</p>
|
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">Collections</p>
|
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
<span v-else class="material-symbols text-lg"></span>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||||
|
<span v-else class="material-symbols text-lg"></span>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
||||||
|
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">Search</p>
|
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
<!-- Series books page -->
|
||||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
<template v-if="selectedSeries">
|
||||||
<div v-else class="items-center hidden md:flex w-full">
|
<p class="pl-2 text-base md:text-lg">
|
||||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
{{ seriesName }}
|
||||||
<span class="material-icons text-2xl text-white">west</span>
|
</p>
|
||||||
</div>
|
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||||
<p class="pl-4 font-book text-lg">
|
<span class="font-mono">{{ numShowing }}</span>
|
||||||
{{ seriesName }}
|
|
||||||
</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 class="flex-grow" />
|
|
||||||
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
|
|
||||||
<div class="h-5 w-5">
|
|
||||||
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
|
||||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
|
||||||
</svg>
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- RSS feed -->
|
||||||
|
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||||
|
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
|
||||||
|
</template>
|
||||||
|
<!-- library & collections page -->
|
||||||
|
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||||
|
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||||
|
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
<div class="flex-grow hidden sm:inline-block" />
|
||||||
|
|
||||||
<ui-checkbox v-show="showSortFilters && !isPodcast" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
<!-- library filter select -->
|
||||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
|
||||||
<!-- <div v-show="showSortFilters" 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> -->
|
|
||||||
|
|
||||||
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
<!-- library sort select -->
|
||||||
|
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||||
|
|
||||||
|
<!-- series filter select -->
|
||||||
|
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||||
|
|
||||||
|
<!-- series sort select -->
|
||||||
|
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||||
|
|
||||||
|
<!-- issues page remove all button -->
|
||||||
|
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
|
|
||||||
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
|
<!-- search page -->
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
|
||||||
<span class="material-icons text-3xl text-white">west</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>Search results for "{{ searchQuery }}"</p>
|
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
|
<!-- authors page -->
|
||||||
<template v-else-if="page === 'authors'">
|
<template v-else-if="page === 'authors'">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">Match All Authors</ui-btn>
|
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||||
|
|
||||||
|
<!-- author sort select -->
|
||||||
|
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||||
|
</template>
|
||||||
|
<!-- home page -->
|
||||||
|
<template v-else-if="isHome">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +118,6 @@ export default {
|
|||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String,
|
|
||||||
authors: {
|
authors: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -96,14 +128,97 @@ export default {
|
|||||||
settings: {},
|
settings: {},
|
||||||
hasInit: false,
|
hasInit: false,
|
||||||
totalEntities: 0,
|
totalEntities: 0,
|
||||||
keywordFilter: null,
|
|
||||||
keywordTimeout: null,
|
|
||||||
processingSeries: false,
|
processingSeries: false,
|
||||||
processingIssues: false,
|
processingIssues: false,
|
||||||
processingAuthors: false
|
processingAuthors: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
seriesContextMenuItems() {
|
||||||
|
if (!this.selectedSeries) return []
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
|
||||||
|
action: 'mark-series-finished'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelOpenRSSFeed,
|
||||||
|
action: 'open-rss-feed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSeriesRemovedFromContinueListening) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelReAddSeriesToContinueListening,
|
||||||
|
action: 're-add-to-continue-listening'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addSubtitlesMenuItem(items)
|
||||||
|
this.addCollapseSubSeriesMenuItem(items)
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
seriesSortItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelName,
|
||||||
|
value: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNumberOfBooks,
|
||||||
|
value: 'numBooks'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLastBookAdded,
|
||||||
|
value: 'lastBookAdded'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLastBookUpdated,
|
||||||
|
value: 'lastBookUpdated'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTotalDuration,
|
||||||
|
value: 'totalDuration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
authorSortItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthorFirstLast,
|
||||||
|
value: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthorLastFirst,
|
||||||
|
value: 'lastFirst'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNumberOfBooks,
|
||||||
|
value: 'numBooks'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelUpdatedAt,
|
||||||
|
value: 'updatedAt'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -113,45 +228,70 @@ export default {
|
|||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
isPodcast() {
|
userCanDownload() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
isGridMode() {
|
currentLibraryId() {
|
||||||
return this.viewMode === 'grid'
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
showSortFilters() {
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'book'
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusicLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'music'
|
||||||
|
},
|
||||||
|
isLibraryPage() {
|
||||||
return this.page === ''
|
return this.page === ''
|
||||||
},
|
},
|
||||||
|
isSeriesPage() {
|
||||||
|
return this.page === 'series'
|
||||||
|
},
|
||||||
|
isCollectionsPage() {
|
||||||
|
return this.page === 'collections'
|
||||||
|
},
|
||||||
|
isPlaylistsPage() {
|
||||||
|
return this.page === 'playlists'
|
||||||
|
},
|
||||||
|
isHomePage() {
|
||||||
|
return this.$route.name === 'library-library'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
|
},
|
||||||
|
isPodcastLatestPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
|
},
|
||||||
|
isAuthorsPage() {
|
||||||
|
return this.$route.name === 'library-library-authors'
|
||||||
|
},
|
||||||
|
isAlbumsPage() {
|
||||||
|
return this.page === 'albums'
|
||||||
|
},
|
||||||
numShowing() {
|
numShowing() {
|
||||||
return this.totalEntities
|
return this.totalEntities
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
if (this.isPodcast) return 'Podcasts'
|
if (this.isAlbumsPage) return 'Albums'
|
||||||
if (!this.page) return 'Books'
|
if (this.isMusicLibrary) return 'Tracks'
|
||||||
if (this.page === 'series') return 'Series'
|
|
||||||
if (this.page === 'collections') return 'Collections'
|
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||||
|
if (!this.page) return this.$strings.LabelBooks
|
||||||
|
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||||
|
if (this.isCollectionsPage) return this.$strings.LabelCollections
|
||||||
|
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
paramId() {
|
seriesId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.selectedSeries ? this.selectedSeries.id : null
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
currentLibraryMediaType() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
|
||||||
},
|
|
||||||
isPodcastLibrary() {
|
|
||||||
return this.currentLibraryMediaType === 'podcast'
|
|
||||||
},
|
|
||||||
homePage() {
|
|
||||||
return this.$route.name === 'library-library'
|
|
||||||
},
|
|
||||||
libraryBookshelfPage() {
|
|
||||||
return this.$route.name === 'library-library-bookshelf-id'
|
|
||||||
},
|
|
||||||
showLibrary() {
|
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
|
||||||
},
|
},
|
||||||
seriesName() {
|
seriesName() {
|
||||||
return this.selectedSeries ? this.selectedSeries.name : null
|
return this.selectedSeries ? this.selectedSeries.name : null
|
||||||
@@ -159,24 +299,190 @@ export default {
|
|||||||
seriesProgress() {
|
seriesProgress() {
|
||||||
return this.selectedSeries ? this.selectedSeries.progress : null
|
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||||
},
|
},
|
||||||
|
seriesRssFeed() {
|
||||||
|
return this.selectedSeries ? this.selectedSeries.rssFeed : null
|
||||||
|
},
|
||||||
seriesLibraryItemIds() {
|
seriesLibraryItemIds() {
|
||||||
if (!this.seriesProgress) return []
|
if (!this.seriesProgress) return []
|
||||||
return this.seriesProgress.libraryItemIds || []
|
return this.seriesProgress.libraryItemIds || []
|
||||||
},
|
},
|
||||||
|
isBatchSelecting() {
|
||||||
|
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||||
|
},
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.seriesProgress && !!this.seriesProgress.isFinished
|
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||||
},
|
},
|
||||||
|
isSeriesRemovedFromContinueListening() {
|
||||||
|
if (!this.seriesId) return false
|
||||||
|
return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)
|
||||||
|
},
|
||||||
filterBy() {
|
filterBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
},
|
},
|
||||||
isPodcastSearchPage() {
|
contextMenuItems() {
|
||||||
return this.$route.name === 'library-library-podcast-search'
|
const items = []
|
||||||
|
|
||||||
|
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExportOPML,
|
||||||
|
action: 'export-opml'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addSubtitlesMenuItem(items)
|
||||||
|
this.addCollapseSeriesMenuItem(items)
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
showPlaylists() {
|
||||||
|
return this.$store.state.libraries.numUserPlaylists > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
addSubtitlesMenuItem(items) {
|
||||||
|
if (this.isBookLibrary && (!this.page || this.page === 'search')) {
|
||||||
|
if (this.settings.showSubtitles) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelHideSubtitles,
|
||||||
|
action: 'hide-subtitles'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelShowSubtitles,
|
||||||
|
action: 'show-subtitles'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addCollapseSeriesMenuItem(items) {
|
||||||
|
if (this.isLibraryPage && this.isBookLibrary && !this.isBatchSelecting) {
|
||||||
|
if (this.settings.collapseSeries) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExpandSeries,
|
||||||
|
action: 'expand-series'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelCollapseSeries,
|
||||||
|
action: 'collapse-series'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addCollapseSubSeriesMenuItem(items) {
|
||||||
|
if (this.selectedSeries && this.isBookLibrary && !this.isBatchSelecting) {
|
||||||
|
if (this.settings.collapseBookSeries) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExpandSubSeries,
|
||||||
|
action: 'expand-sub-series'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelCollapseSubSeries,
|
||||||
|
action: 'collapse-sub-series'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleSubtitlesAction(action) {
|
||||||
|
if (action === 'show-subtitles') {
|
||||||
|
this.settings.showSubtitles = true
|
||||||
|
this.updateShowSubtitles()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (action === 'hide-subtitles') {
|
||||||
|
this.settings.showSubtitles = false
|
||||||
|
this.updateShowSubtitles()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleCollapseSeriesAction(action) {
|
||||||
|
if (action === 'collapse-series') {
|
||||||
|
this.settings.collapseSeries = true
|
||||||
|
this.updateCollapseSeries()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (action === 'expand-series') {
|
||||||
|
this.settings.collapseSeries = false
|
||||||
|
this.updateCollapseSeries()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleCollapseSubSeriesAction(action) {
|
||||||
|
if (action === 'collapse-sub-series') {
|
||||||
|
this.settings.collapseBookSeries = true
|
||||||
|
this.updateCollapseSubSeries()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (action === 'expand-sub-series') {
|
||||||
|
this.settings.collapseBookSeries = false
|
||||||
|
this.updateCollapseSubSeries()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
contextMenuAction({ action }) {
|
||||||
|
if (action === 'export-opml') {
|
||||||
|
this.exportOPML()
|
||||||
|
return
|
||||||
|
} else if (this.handleSubtitlesAction(action)) {
|
||||||
|
return
|
||||||
|
} else if (this.handleCollapseSeriesAction(action)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exportOPML() {
|
||||||
|
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
||||||
|
},
|
||||||
|
seriesContextMenuAction({ action }) {
|
||||||
|
if (action === 'open-rss-feed') {
|
||||||
|
this.showOpenSeriesRSSFeed()
|
||||||
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
|
if (this.processingSeries) {
|
||||||
|
console.warn('Already processing series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.reAddSeriesToContinueListening()
|
||||||
|
} else if (action === 'mark-series-finished') {
|
||||||
|
if (this.processingSeries) {
|
||||||
|
console.warn('Already processing series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.markSeriesFinished()
|
||||||
|
} else if (this.handleSubtitlesAction(action)) {
|
||||||
|
return
|
||||||
|
} else if (this.handleCollapseSubSeriesAction(action)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showOpenSeriesRSSFeed() {
|
||||||
|
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||||
|
id: this.selectedSeries.id,
|
||||||
|
name: this.selectedSeries.name,
|
||||||
|
type: 'series',
|
||||||
|
feed: this.selectedSeries.rssFeed
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reAddSeriesToContinueListening() {
|
||||||
|
this.processingSeries = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to re-add series to continue listening', error)
|
||||||
|
this.$toast.error(this.$strings.ToastItemUpdateFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
},
|
||||||
async matchAllAuthors() {
|
async matchAllAuthors() {
|
||||||
this.processingAuthors = true
|
this.processingAuthors = true
|
||||||
|
|
||||||
@@ -184,7 +490,11 @@ export default {
|
|||||||
const payload = {}
|
const payload = {}
|
||||||
if (author.asin) payload.asin = author.asin
|
if (author.asin) payload.asin = author.asin
|
||||||
else payload.q = author.name
|
else payload.q = author.name
|
||||||
console.log('Payload', payload, 'author', author)
|
|
||||||
|
payload.region = 'us'
|
||||||
|
if (this.libraryProvider.startsWith('audible.')) {
|
||||||
|
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||||
|
}
|
||||||
|
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
@@ -194,7 +504,7 @@ export default {
|
|||||||
})
|
})
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error(`Author ${author.name} not found`)
|
console.error(`Author ${author.name} not found`)
|
||||||
this.$toast.error(`Author ${author.name} not found`)
|
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
|
||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||||
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||||
@@ -212,45 +522,52 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Removed library items with issues')
|
this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
this.processingIssues = false
|
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove library items with issues', error)
|
console.error('Failed to remove library items with issues', error)
|
||||||
this.$toast.error('Failed to remove library items with issues')
|
this.$toast.error(this.$strings.ToastRemoveItemsWithIssuesFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
this.processingIssues = false
|
this.processingIssues = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markSeriesFinished() {
|
markSeriesFinished() {
|
||||||
var newIsFinished = !this.isSeriesFinished
|
const newIsFinished = !this.isSeriesFinished
|
||||||
this.processingSeries = true
|
|
||||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
const payload = {
|
||||||
return {
|
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
|
||||||
id: lid,
|
callback: (confirmed) => {
|
||||||
isFinished: newIsFinished
|
if (confirmed) {
|
||||||
}
|
this.processingSeries = true
|
||||||
})
|
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||||
console.log('Progress payloads', updateProgressPayloads)
|
return {
|
||||||
this.$axios
|
libraryItemId: lid,
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
isFinished: newIsFinished
|
||||||
.then(() => {
|
}
|
||||||
this.$toast.success('Series update success')
|
})
|
||||||
this.selectedSeries.progress.isFinished = newIsFinished
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
this.processingSeries = false
|
this.$axios
|
||||||
})
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.catch((error) => {
|
.then(() => {
|
||||||
this.$toast.error('Series update failed')
|
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
|
||||||
console.error('Failed to batch update read/not read', error)
|
this.selectedSeries.progress.isFinished = newIsFinished
|
||||||
this.processingSeries = false
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
},
|
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
|
||||||
searchBackArrow() {
|
console.error('Failed to batch update read/not read', error)
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
})
|
||||||
},
|
.finally(() => {
|
||||||
seriesBackArrow() {
|
this.processingSeries = false
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
updateOrder() {
|
updateOrder() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
@@ -258,9 +575,24 @@ export default {
|
|||||||
updateFilter() {
|
updateFilter() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
updateSeriesSort() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
|
updateSeriesFilter() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
updateCollapseSeries() {
|
updateCollapseSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
updateCollapseSubSeries() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
|
updateShowSubtitles() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
|
updateAuthorSort() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||||
},
|
},
|
||||||
@@ -275,24 +607,31 @@ export default {
|
|||||||
setBookshelfTotalEntities(totalEntities) {
|
setBookshelfTotalEntities(totalEntities) {
|
||||||
this.totalEntities = totalEntities
|
this.totalEntities = totalEntities
|
||||||
},
|
},
|
||||||
keywordFilterInput() {
|
rssFeedOpen(data) {
|
||||||
clearTimeout(this.keywordTimeout)
|
if (data.entityId === this.seriesId) {
|
||||||
this.keywordTimeout = setTimeout(() => {
|
console.log('RSS Feed Opened', data)
|
||||||
this.keywordUpdated(this.keywordFilter)
|
this.selectedSeries.rssFeed = data
|
||||||
}, 1000)
|
}
|
||||||
},
|
},
|
||||||
keywordUpdated() {
|
rssFeedClosed(data) {
|
||||||
this.$eventBus.$emit('bookshelf-keyword-filter', this.keywordFilter)
|
if (data.entityId === this.seriesId) {
|
||||||
|
console.log('RSS Feed Closed', data)
|
||||||
|
this.selectedSeries.rssFeed = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.init()
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||||
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||||
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -302,4 +641,4 @@ export default {
|
|||||||
#toolbar {
|
#toolbar {
|
||||||
box-shadow: 0px 8px 6px #111111aa;
|
box-shadow: 0px 8px 6px #111111aa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
|
<div>
|
||||||
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||||
<span class="material-icons text-2xl">arrow_back</span>
|
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||||
</div>
|
<span class="material-symbols text-2xl">arrow_back</span>
|
||||||
|
|
||||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>{{ route.title }}</p>
|
|
||||||
<div v-show="routeName === route.iod" 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 left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
|
||||||
|
|
||||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
|
||||||
|
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p class="leading-4">{{ route.title }}</p>
|
||||||
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
|
</div>
|
||||||
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ $config.version }}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ export default {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: this.$strings.HeaderYourStats,
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -55,45 +57,70 @@ export default {
|
|||||||
const configRoutes = [
|
const configRoutes = [
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
title: 'Settings',
|
title: this.$strings.HeaderSettings,
|
||||||
path: '/config'
|
path: '/config'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-libraries',
|
id: 'config-libraries',
|
||||||
title: 'Libraries',
|
title: this.$strings.HeaderLibraries,
|
||||||
path: '/config/libraries'
|
path: '/config/libraries'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-users',
|
id: 'config-users',
|
||||||
title: 'Users',
|
title: this.$strings.HeaderUsers,
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-sessions',
|
id: 'config-sessions',
|
||||||
title: 'Listening Sessions',
|
title: this.$strings.HeaderListeningSessions,
|
||||||
path: '/config/sessions'
|
path: '/config/sessions'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-backups',
|
id: 'config-backups',
|
||||||
title: 'Backups',
|
title: this.$strings.HeaderBackups,
|
||||||
path: '/config/backups'
|
path: '/config/backups'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Logs',
|
title: this.$strings.HeaderLogs,
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-notifications',
|
||||||
|
title: this.$strings.HeaderNotifications,
|
||||||
|
path: '/config/notifications'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-email',
|
||||||
|
title: this.$strings.HeaderEmail,
|
||||||
|
path: '/config/email'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-item-metadata-utils',
|
||||||
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
|
path: '/config/item-metadata-utils'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-rss-feeds',
|
||||||
|
title: this.$strings.HeaderRSSFeeds,
|
||||||
|
path: '/config/rss-feeds'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-authentication',
|
||||||
|
title: this.$strings.HeaderAuthentication,
|
||||||
|
path: '/config/authentication'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.currentLibraryId) {
|
if (this.currentLibraryId) {
|
||||||
configRoutes.push({
|
configRoutes.push({
|
||||||
id: 'config-library-stats',
|
id: 'library-stats',
|
||||||
title: 'Library Stats',
|
title: this.$strings.HeaderLibraryStats,
|
||||||
path: '/config/library-stats'
|
path: `/library/${this.currentLibraryId}/stats`
|
||||||
})
|
})
|
||||||
configRoutes.push({
|
configRoutes.push({
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: this.$strings.HeaderYourStats,
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,7 +131,7 @@ export default {
|
|||||||
var classes = []
|
var classes = []
|
||||||
if (this.drawerOpen) classes.push('translate-x-0')
|
if (this.drawerOpen) classes.push('translate-x-0')
|
||||||
else classes.push('-translate-x-44')
|
else classes.push('-translate-x-44')
|
||||||
if (this.isMobile) classes.push('z-50')
|
if (this.isMobilePortrait) classes.push('z-50')
|
||||||
else classes.push('z-40')
|
else classes.push('z-40')
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
@@ -114,9 +141,11 @@ export default {
|
|||||||
isMobileLandscape() {
|
isMobileLandscape() {
|
||||||
return this.$store.state.globals.isMobileLandscape
|
return this.$store.state.globals.isMobileLandscape
|
||||||
},
|
},
|
||||||
|
isMobilePortrait() {
|
||||||
|
return this.$store.state.globals.isMobilePortrait
|
||||||
|
},
|
||||||
drawerOpen() {
|
drawerOpen() {
|
||||||
if (this.isMobile) return this.isOpen
|
return !this.isMobilePortrait || this.isOpen
|
||||||
return true
|
|
||||||
},
|
},
|
||||||
routeName() {
|
routeName() {
|
||||||
return this.$route.name
|
return this.$route.name
|
||||||
@@ -127,21 +156,15 @@ export default {
|
|||||||
hasUpdate() {
|
hasUpdate() {
|
||||||
return !!this.versionData.hasUpdate
|
return !!this.versionData.hasUpdate
|
||||||
},
|
},
|
||||||
latestVersion() {
|
|
||||||
return this.versionData.latestVersion
|
|
||||||
},
|
|
||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
currentVersionChangelog() {
|
|
||||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
|
||||||
},
|
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickChangelog(){
|
clickChangelog() {
|
||||||
this.showChangelogModal = true
|
this.showChangelogModal = true
|
||||||
},
|
},
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
@@ -150,7 +173,7 @@ export default {
|
|||||||
},
|
},
|
||||||
closeDrawer() {
|
closeDrawer() {
|
||||||
this.$emit('update:isOpen', false)
|
this.$emit('update:isOpen', false)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" class="w-full overflow-y-auto">
|
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||||
<template v-for="shelf in totalShelves">
|
<template v-for="shelf in totalShelves">
|
||||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
|
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div v-if="userIsAdminOrUp" class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||||
<!-- Clear filter only available on Library bookshelf -->
|
<!-- Clear filter only available on Library bookshelf -->
|
||||||
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
|
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
||||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -49,10 +49,9 @@ export default {
|
|||||||
entityIndexesMounted: [],
|
entityIndexesMounted: [],
|
||||||
entityComponentRefs: {},
|
entityComponentRefs: {},
|
||||||
currentBookWidth: 0,
|
currentBookWidth: 0,
|
||||||
pageLoadQueue: [],
|
|
||||||
isFetchingEntities: false,
|
isFetchingEntities: false,
|
||||||
scrollTimeout: null,
|
scrollTimeout: null,
|
||||||
booksPerFetch: 100,
|
booksPerFetch: 0,
|
||||||
totalShelves: 0,
|
totalShelves: 0,
|
||||||
bookshelfMarginLeft: 0,
|
bookshelfMarginLeft: 0,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
@@ -61,7 +60,12 @@ export default {
|
|||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
currScrollTop: 0,
|
currScrollTop: 0,
|
||||||
resizeTimeout: null,
|
resizeTimeout: null,
|
||||||
mountWindowWidth: 0
|
mountWindowWidth: 0,
|
||||||
|
lastItemIndexSelected: -1,
|
||||||
|
tempIsScanning: false,
|
||||||
|
cardWidth: 0,
|
||||||
|
cardHeight: 0,
|
||||||
|
resizeObserver: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -77,26 +81,36 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
libraryMediaType() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
return this.libraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
emptyMessage() {
|
emptyMessage() {
|
||||||
if (this.page === 'series') return 'You have no series'
|
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
|
||||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
|
||||||
|
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
|
||||||
if (this.hasFilter) {
|
if (this.hasFilter) {
|
||||||
if (this.filterName === 'Issues') return 'No Issues'
|
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
||||||
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
|
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
||||||
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
|
||||||
}
|
}
|
||||||
return 'No results'
|
return this.$strings.MessageNoResults
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
if (!this.page) return 'books'
|
if (!this.page) return 'items'
|
||||||
return this.page
|
return this.page
|
||||||
},
|
},
|
||||||
|
seriesSortBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('seriesSortBy')
|
||||||
|
},
|
||||||
|
seriesSortDesc() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
|
||||||
|
},
|
||||||
|
seriesFilterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
||||||
|
},
|
||||||
orderBy() {
|
orderBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||||
},
|
},
|
||||||
@@ -109,6 +123,9 @@ export default {
|
|||||||
collapseSeries() {
|
collapseSeries() {
|
||||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||||
},
|
},
|
||||||
|
collapseBookSeries() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
|
||||||
|
},
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -122,7 +139,7 @@ export default {
|
|||||||
return this.$store.getters['getBookshelfView']
|
return this.$store.getters['getBookshelfView']
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
return this.bookshelfView === this.$constants.BookshelfView.DETAIL
|
||||||
},
|
},
|
||||||
hasFilter() {
|
hasFilter() {
|
||||||
return this.filterBy && this.filterBy !== 'all'
|
return this.filterBy && this.filterBy !== 'all'
|
||||||
@@ -144,55 +161,47 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
isEntityBook() {
|
|
||||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
|
||||||
},
|
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
return this.cardWidth
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
|
||||||
return coverSize
|
|
||||||
},
|
},
|
||||||
bookHeight() {
|
bookHeight() {
|
||||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
return this.cardHeight
|
||||||
return this.bookWidth * 1.6
|
|
||||||
},
|
},
|
||||||
shelfPadding() {
|
shelfPadding() {
|
||||||
if (this.bookshelfWidth < 640) return 32
|
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
||||||
return 64
|
return 64 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
totalPadding() {
|
totalPadding() {
|
||||||
return this.shelfPadding * 2
|
return this.shelfPadding * 2
|
||||||
},
|
},
|
||||||
entityWidth() {
|
entityWidth() {
|
||||||
if (this.entityName === 'series' || this.entityName === 'collections') {
|
return this.cardWidth
|
||||||
if (this.bookWidth * 2 > this.bookshelfWidth - this.shelfPadding) return this.bookWidth * 1.6
|
|
||||||
return this.bookWidth * 2
|
|
||||||
}
|
|
||||||
return this.bookWidth
|
|
||||||
},
|
},
|
||||||
entityHeight() {
|
entityHeight() {
|
||||||
return this.bookHeight
|
return this.cardHeight
|
||||||
},
|
},
|
||||||
shelfDividerHeightIndex() {
|
shelfPaddingHeight() {
|
||||||
return 6
|
return 16
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.isAlternativeBookshelfView) {
|
const dividerHeight = this.isAlternativeBookshelfView ? 0 : 24 // h-6
|
||||||
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
return this.cardHeight + (this.shelfPaddingHeight + dividerHeight) * this.sizeMultiplier
|
||||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
|
||||||
}
|
|
||||||
return this.entityHeight + 40
|
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
selectedLibraryItems() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.selectedLibraryItems || []
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
return this.entityWidth / baseSize
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
isScanningLibrary() {
|
||||||
|
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -200,23 +209,82 @@ export default {
|
|||||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||||
},
|
},
|
||||||
editEntity(entity) {
|
editEntity(entity) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var bookIds = this.entities.map((e) => e.id)
|
const bookIds = this.entities.map((e) => e.id)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||||
this.$store.commit('showEditModal', entity)
|
this.$store.commit('showEditModal', entity)
|
||||||
} else if (this.entityName === 'collections') {
|
} else if (this.entityName === 'collections') {
|
||||||
this.$store.commit('globals/setEditCollection', entity)
|
this.$store.commit('globals/setEditCollection', entity)
|
||||||
|
} else if (this.entityName === 'playlists') {
|
||||||
|
this.$store.commit('globals/setEditPlaylist', entity)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearSelectedEntities() {
|
clearSelectedEntities() {
|
||||||
this.updateBookSelectionMode(false)
|
this.updateBookSelectionMode(false)
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
},
|
},
|
||||||
selectEntity(entity) {
|
selectEntity(entity, shiftKey) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
|
||||||
|
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||||
|
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||||
|
this.lastItemIndexSelected = indexOf
|
||||||
|
} else {
|
||||||
|
this.lastItemIndexSelected = -1
|
||||||
|
}
|
||||||
|
|
||||||
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||||
|
let loopStart = indexOf
|
||||||
|
let loopEnd = lastLastItemIndexSelected
|
||||||
|
if (indexOf > lastLastItemIndexSelected) {
|
||||||
|
loopStart = lastLastItemIndexSelected
|
||||||
|
loopEnd = indexOf
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSelecting = false
|
||||||
|
// If any items in this range is not selected then select all otherwise unselect all
|
||||||
|
for (let i = loopStart; i <= loopEnd; i++) {
|
||||||
|
const thisEntity = this.entities[i]
|
||||||
|
if (thisEntity && !thisEntity.collapsedSeries) {
|
||||||
|
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||||
|
isSelecting = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSelecting) this.lastItemIndexSelected = indexOf
|
||||||
|
|
||||||
|
for (let i = loopStart; i <= loopEnd; i++) {
|
||||||
|
const thisEntity = this.entities[i]
|
||||||
|
if (thisEntity.collapsedSeries) {
|
||||||
|
console.warn('Ignoring collapsed series')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityComponentRef = this.entityComponentRefs[i]
|
||||||
|
if (thisEntity && entityComponentRef) {
|
||||||
|
entityComponentRef.selected = isSelecting
|
||||||
|
|
||||||
|
const mediaItem = {
|
||||||
|
id: thisEntity.id,
|
||||||
|
mediaType: thisEntity.mediaType,
|
||||||
|
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||||
|
} else {
|
||||||
|
console.error('Invalid entity index', i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIsSelectionMode = !!this.selectedMediaItems.length
|
||||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||||
this.isSelectionMode = newIsSelectionMode
|
this.isSelectionMode = newIsSelectionMode
|
||||||
this.updateBookSelectionMode(newIsSelectionMode)
|
this.updateBookSelectionMode(newIsSelectionMode)
|
||||||
@@ -229,9 +297,12 @@ export default {
|
|||||||
this.entityComponentRefs[key].setSelectionMode(isSelectionMode)
|
this.entityComponentRefs[key].setSelectionMode(isSelectionMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
this.lastItemIndexSelected = -1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async fetchEntites(page = 0) {
|
async fetchEntites(page = 0) {
|
||||||
var startIndex = page * this.booksPerFetch
|
const startIndex = page * this.booksPerFetch
|
||||||
|
|
||||||
this.isFetchingEntities = true
|
this.isFetchingEntities = true
|
||||||
|
|
||||||
@@ -239,12 +310,12 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`
|
||||||
|
|
||||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch books', error)
|
console.error('failed to fetch items', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -260,16 +331,17 @@ export default {
|
|||||||
this.totalEntities = payload.total
|
this.totalEntities = payload.total
|
||||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
||||||
this.entities = new Array(this.totalEntities)
|
this.entities = new Array(this.totalEntities)
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < payload.results.length; i++) {
|
for (let i = 0; i < payload.results.length; i++) {
|
||||||
var index = i + startIndex
|
const index = i + startIndex
|
||||||
this.entities[index] = payload.results[i]
|
this.entities[index] = payload.results[i]
|
||||||
if (this.entityComponentRefs[index]) {
|
if (this.entityComponentRefs[index]) {
|
||||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loadPage(page) {
|
loadPage(page) {
|
||||||
@@ -354,10 +426,14 @@ export default {
|
|||||||
rebuild() {
|
rebuild() {
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
|
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||||
this.entityIndexesMounted = []
|
this.entityIndexesMounted = []
|
||||||
for (let i = 0; i < lastBookIndex; i++) {
|
for (let i = 0; i < lastBookIndex; i++) {
|
||||||
this.entityIndexesMounted.push(i)
|
this.entityIndexesMounted.push(i)
|
||||||
|
if (!this.entities[i]) {
|
||||||
|
const page = Math.floor(i / this.booksPerFetch)
|
||||||
|
this.loadPage(page)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var bookshelfEl = document.getElementById('bookshelf')
|
var bookshelfEl = document.getElementById('bookshelf')
|
||||||
if (bookshelfEl) {
|
if (bookshelfEl) {
|
||||||
@@ -367,13 +443,20 @@ export default {
|
|||||||
this.$nextTick(this.remountEntities)
|
this.$nextTick(this.remountEntities)
|
||||||
},
|
},
|
||||||
buildSearchParams() {
|
buildSearchParams() {
|
||||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
if (this.page === 'search' || this.page === 'collections') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchParams = new URLSearchParams()
|
let searchParams = new URLSearchParams()
|
||||||
if (this.page === 'series-books') {
|
if (this.page === 'series') {
|
||||||
|
searchParams.set('sort', this.seriesSortBy)
|
||||||
|
searchParams.set('desc', this.seriesSortDesc ? 1 : 0)
|
||||||
|
searchParams.set('filter', this.seriesFilterBy)
|
||||||
|
} else if (this.page === 'series-books') {
|
||||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||||
|
if (this.collapseBookSeries) {
|
||||||
|
searchParams.set('collapseseries', 1)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.filterBy && this.filterBy !== 'all') {
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
searchParams.set('filter', this.filterBy)
|
searchParams.set('filter', this.filterBy)
|
||||||
@@ -389,8 +472,6 @@ export default {
|
|||||||
return searchParams.toString()
|
return searchParams.toString()
|
||||||
},
|
},
|
||||||
checkUpdateSearchParams() {
|
checkUpdateSearchParams() {
|
||||||
if (this.page === 'series-books') return false
|
|
||||||
|
|
||||||
var newSearchParams = this.buildSearchParams()
|
var newSearchParams = this.buildSearchParams()
|
||||||
var currentQueryString = window.location.search
|
var currentQueryString = window.location.search
|
||||||
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
||||||
@@ -408,8 +489,15 @@ export default {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
seriesSortUpdated() {
|
||||||
var wasUpdated = this.checkUpdateSearchParams()
|
var wasUpdated = this.checkUpdateSearchParams()
|
||||||
|
if (wasUpdated) {
|
||||||
|
this.resetEntities()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async settingsUpdated(settings) {
|
||||||
|
await this.cardsHelpers.setCardSize()
|
||||||
|
const wasUpdated = this.checkUpdateSearchParams()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||||
@@ -419,10 +507,7 @@ export default {
|
|||||||
scroll(e) {
|
scroll(e) {
|
||||||
if (!e || !e.target) return
|
if (!e || !e.target) return
|
||||||
var { scrollTop } = e.target
|
var { scrollTop } = e.target
|
||||||
// clearTimeout(this.scrollTimeout)
|
|
||||||
// this.scrollTimeout = setTimeout(() => {
|
|
||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
// }, 250)
|
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('libraryItem added', libraryItem)
|
||||||
@@ -431,7 +516,7 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItemUpdated(libraryItem) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Item updated', libraryItem)
|
console.log('Item updated', libraryItem)
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities[indexOf] = libraryItem
|
this.entities[indexOf] = libraryItem
|
||||||
@@ -442,11 +527,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemRemoved(libraryItem) {
|
libraryItemRemoved(libraryItem) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||||
this.totalEntities = this.entities.length
|
this.totalEntities--
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
@@ -484,11 +569,76 @@ export default {
|
|||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||||
this.totalEntities = this.entities.length
|
this.totalEntities--
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
playlistAdded(playlist) {
|
||||||
|
if (this.entityName !== 'playlists') return
|
||||||
|
console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)
|
||||||
|
this.resetEntities()
|
||||||
|
},
|
||||||
|
playlistUpdated(playlist) {
|
||||||
|
if (this.entityName !== 'playlists') return
|
||||||
|
console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities[indexOf] = playlist
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
this.entityComponentRefs[indexOf].setEntity(playlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playlistRemoved(playlist) {
|
||||||
|
if (this.entityName !== 'playlists') return
|
||||||
|
console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities = this.entities.filter((ent) => ent.id !== playlist.id)
|
||||||
|
this.totalEntities--
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
|
this.executeRebuild()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shareOpen(mediaItemShare) {
|
||||||
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||||
|
libraryItem.mediaItemShare = mediaItemShare
|
||||||
|
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shareClosed(mediaItemShare) {
|
||||||
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||||
|
libraryItem.mediaItemShare = null
|
||||||
|
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePagesLoaded() {
|
||||||
|
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
||||||
|
for (let page = 0; page < numPages; page++) {
|
||||||
|
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
||||||
|
this.pagesLoaded[page] = true
|
||||||
|
for (let i = 0; i < numEntities; i++) {
|
||||||
|
const index = page * this.booksPerFetch + i
|
||||||
|
if (!this.entities[index]) {
|
||||||
|
this.pagesLoaded[page] = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
initSizeData(_bookshelf) {
|
initSizeData(_bookshelf) {
|
||||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||||
if (!bookshelf) {
|
if (!bookshelf) {
|
||||||
@@ -505,6 +655,13 @@ export default {
|
|||||||
this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))
|
this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))
|
||||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||||
|
const booksPerFetch = this.entitiesPerShelf * this.shelvesPerPage
|
||||||
|
if (booksPerFetch !== this.booksPerFetch) {
|
||||||
|
this.booksPerFetch = booksPerFetch
|
||||||
|
if (this.totalEntities) {
|
||||||
|
this.updatePagesLoaded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.currentBookWidth = this.bookWidth
|
this.currentBookWidth = this.bookWidth
|
||||||
if (this.totalEntities) {
|
if (this.totalEntities) {
|
||||||
@@ -513,8 +670,8 @@ export default {
|
|||||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||||
},
|
},
|
||||||
async init(bookshelf) {
|
async init(bookshelf) {
|
||||||
this.checkUpdateSearchParams()
|
|
||||||
this.initSizeData(bookshelf)
|
this.initSizeData(bookshelf)
|
||||||
|
this.checkUpdateSearchParams()
|
||||||
|
|
||||||
this.pagesLoaded[0] = true
|
this.pagesLoaded[0] = true
|
||||||
await this.fetchEntites(0)
|
await this.fetchEntites(0)
|
||||||
@@ -554,10 +711,9 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$on('socket_init', this.socketInit)
|
this.$eventBus.$on('socket_init', this.socketInit)
|
||||||
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
@@ -568,6 +724,11 @@ export default {
|
|||||||
this.$root.socket.on('collection_added', this.collectionAdded)
|
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||||
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||||
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
||||||
|
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||||
|
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||||
|
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||||
|
this.$root.socket.on('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -578,10 +739,10 @@ export default {
|
|||||||
if (bookshelf) {
|
if (bookshelf) {
|
||||||
bookshelf.removeEventListener('scroll', this.scroll)
|
bookshelf.removeEventListener('scroll', this.scroll)
|
||||||
}
|
}
|
||||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
|
||||||
this.$eventBus.$off('socket_init', this.socketInit)
|
|
||||||
|
|
||||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('socket_init', this.socketInit)
|
||||||
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
@@ -592,6 +753,11 @@ export default {
|
|||||||
this.$root.socket.off('collection_added', this.collectionAdded)
|
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||||
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||||
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
||||||
|
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||||
|
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||||
|
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||||
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -604,18 +770,20 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
|
this.tempIsScanning = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to start scan', error)
|
console.error('Failed to start scan', error)
|
||||||
this.$toast.error('Failed to start scan')
|
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.tempIsScanning = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
|
await this.cardsHelpers.setCardSize()
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
|
||||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
@@ -646,9 +814,10 @@ export default {
|
|||||||
.bookshelfRow {
|
.bookshelfRow {
|
||||||
background-image: var(--bookshelf-texture-img);
|
background-image: var(--bookshelf-texture-img);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookshelfDivider {
|
.bookshelfDivider {
|
||||||
background: rgb(149, 119, 90);
|
background: rgb(149, 119, 90);
|
||||||
background: var(--bookshelf-divider-bg);
|
background: var(--bookshelf-divider-bg);
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
box-shadow: 0.125em 0.875em 0.5em #111111aa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+219
-63
@@ -1,54 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||||
<div id="videoDock" />
|
<div id="videoDock" />
|
||||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</nuxt-link>
|
</div>
|
||||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||||
<div>
|
<div class="min-w-0 w-full">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
<div class="flex items-center">
|
||||||
{{ title }}
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
</nuxt-link>
|
{{ title }}
|
||||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
</nuxt-link>
|
||||||
<span class="material-icons text-sm">person</span>
|
<widgets-explicit-indicator v-if="isExplicit" />
|
||||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
</div>
|
||||||
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||||
|
<span class="material-symbols text-sm">person</span>
|
||||||
|
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||||
|
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||||
|
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</div>
|
||||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
|
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-xs">schedule</span>
|
<span class="material-symbols text-xs">schedule</span>
|
||||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||||
|
<button :aria-label="$strings.LabelClosePlayer" class="material-symbols sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<player-ui
|
<player-ui
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
|
:current-chapter="currentChapter"
|
||||||
:paused="!isPlaying"
|
:paused="!isPlaying"
|
||||||
:loading="playerLoading"
|
:loading="playerLoading"
|
||||||
:bookmarks="bookmarks"
|
:bookmarks="bookmarks"
|
||||||
:sleep-timer-set="sleepTimerSet"
|
:sleep-timer-set="sleepTimerSet"
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
|
:sleep-timer-type="sleepTimerType"
|
||||||
:is-podcast="isPodcast"
|
:is-podcast="isPodcast"
|
||||||
|
:hasNextItemInQueue="hasNextItemInQueue"
|
||||||
@playPause="playPause"
|
@playPause="playPause"
|
||||||
@jumpForward="jumpForward"
|
@jumpForward="jumpForward"
|
||||||
@jumpBackward="jumpBackward"
|
@jumpBackward="jumpBackward"
|
||||||
@setVolume="setVolume"
|
@setVolume="setVolume"
|
||||||
@setPlaybackRate="setPlaybackRate"
|
@setPlaybackRate="setPlaybackRate"
|
||||||
@seek="seek"
|
@seek="seek"
|
||||||
|
@nextItemInQueue="playNextItemInQueue"
|
||||||
@close="closePlayer"
|
@close="closePlayer"
|
||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
|
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||||
|
|
||||||
|
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -66,25 +82,28 @@ export default {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
|
showPlayerQueueItemsModal: false,
|
||||||
|
showPlayerSettingsModal: false,
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerTime: 0,
|
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
|
sleepTimerType: null,
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
initialPlaybackRate: 1,
|
currentPlaybackRate: 1,
|
||||||
syncFailedToast: null
|
syncFailedToast: null,
|
||||||
|
coverAspectRatio: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
coverAspectRatio() {
|
isSquareCover() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.coverAspectRatio === 1
|
||||||
|
},
|
||||||
|
isMobile() {
|
||||||
|
return this.$store.state.globals.isMobile
|
||||||
},
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
return 88
|
if (this.isMobile) return 64 / this.coverAspectRatio
|
||||||
},
|
return 77 / this.coverAspectRatio
|
||||||
bookCoverPosTop() {
|
|
||||||
if (this.coverAspectRatio == 1) return -10
|
|
||||||
return -64
|
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
if (this.media.coverPath) return this.media.coverPath
|
if (this.media.coverPath) return this.media.coverPath
|
||||||
@@ -107,21 +126,36 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
|
streamEpisode() {
|
||||||
|
if (!this.$store.state.streamEpisodeId) return null
|
||||||
|
const episodes = this.streamLibraryItem.media.episodes || []
|
||||||
|
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||||
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
return this.streamLibraryItem?.id || null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
return this.streamLibraryItem?.media || {}
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.streamLibraryItem?.mediaType === 'music'
|
||||||
|
},
|
||||||
|
isExplicit() {
|
||||||
|
return !!this.mediaMetadata.explicit
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
|
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
|
currentChapter() {
|
||||||
|
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
@@ -133,27 +167,76 @@ export default {
|
|||||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
return this.$secondsToTimestamp(this.totalDuration)
|
// Adjusted by playback rate
|
||||||
|
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||||
},
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
return this.mediaMetadata.author || 'Unknown'
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
|
},
|
||||||
|
musicArtists() {
|
||||||
|
if (!this.isMusic) return null
|
||||||
|
return this.mediaMetadata.artists.join(', ')
|
||||||
|
},
|
||||||
|
hasNextItemInQueue() {
|
||||||
|
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
|
||||||
|
},
|
||||||
|
currentPlayerQueueIndex() {
|
||||||
|
if (!this.libraryItemId) return -1
|
||||||
|
return this.playerQueueItems.findIndex((i) => {
|
||||||
|
if (this.streamEpisode?.id) return i.episodeId === this.streamEpisode.id
|
||||||
|
return i.libraryItemId === this.libraryItemId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
playerQueueItems() {
|
||||||
|
return this.$store.state.playerQueueItems || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
mediaFinished(libraryItemId, episodeId) {
|
||||||
|
// Play next item in queue
|
||||||
|
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
|
||||||
|
// TODO: Set media finished flag so play button will play next queue item
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
|
||||||
|
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
|
||||||
|
return i.libraryItemId === libraryItemId
|
||||||
|
})
|
||||||
|
if (currentQueueIndex < 0) {
|
||||||
|
console.error('Media finished not found in queue - using first in queue', this.playerQueueItems)
|
||||||
|
currentQueueIndex = -1
|
||||||
|
}
|
||||||
|
if (currentQueueIndex === this.playerQueueItems.length - 1) {
|
||||||
|
console.log('Finished last item in queue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
|
||||||
|
if (nextItemInQueue) {
|
||||||
|
this.playLibraryItem({
|
||||||
|
libraryItemId: nextItemInQueue.libraryItemId,
|
||||||
|
episodeId: nextItemInQueue.episodeId || null,
|
||||||
|
queueItems: this.playerQueueItems
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
setPlaying(isPlaying) {
|
setPlaying(isPlaying) {
|
||||||
this.isPlaying = isPlaying
|
this.isPlaying = isPlaying
|
||||||
this.$store.commit('setIsPlaying', isPlaying)
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
this.updateMediaSessionPlaybackState()
|
this.updateMediaSessionPlaybackState()
|
||||||
},
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(time) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
this.sleepTimerTime = seconds
|
|
||||||
this.sleepTimerRemaining = seconds
|
|
||||||
this.runSleepTimer()
|
|
||||||
this.showSleepTimerModal = false
|
this.showSleepTimerModal = false
|
||||||
|
|
||||||
|
this.sleepTimerType = time.timerType
|
||||||
|
if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {
|
||||||
|
this.runSleepTimer(time)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
runSleepTimer() {
|
runSleepTimer(time) {
|
||||||
|
this.sleepTimerRemaining = time.seconds
|
||||||
|
|
||||||
var lastTick = Date.now()
|
var lastTick = Date.now()
|
||||||
clearInterval(this.sleepTimer)
|
clearInterval(this.sleepTimer)
|
||||||
this.sleepTimer = setInterval(() => {
|
this.sleepTimer = setInterval(() => {
|
||||||
@@ -162,12 +245,23 @@ export default {
|
|||||||
this.sleepTimerRemaining -= elapsed / 1000
|
this.sleepTimerRemaining -= elapsed / 1000
|
||||||
|
|
||||||
if (this.sleepTimerRemaining <= 0) {
|
if (this.sleepTimerRemaining <= 0) {
|
||||||
this.clearSleepTimer()
|
this.sleepTimerEnd()
|
||||||
this.playerHandler.pause()
|
|
||||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
},
|
},
|
||||||
|
checkChapterEnd(time) {
|
||||||
|
if (!this.currentChapter) return
|
||||||
|
const chapterEndTime = this.currentChapter.end
|
||||||
|
const tolerance = 0.75
|
||||||
|
if (time >= chapterEndTime - tolerance) {
|
||||||
|
this.sleepTimerEnd()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sleepTimerEnd() {
|
||||||
|
this.clearSleepTimer()
|
||||||
|
this.playerHandler.pause()
|
||||||
|
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||||
|
},
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
this.showSleepTimerModal = false
|
this.showSleepTimerModal = false
|
||||||
this.clearSleepTimer()
|
this.clearSleepTimer()
|
||||||
@@ -177,6 +271,7 @@ export default {
|
|||||||
this.sleepTimerRemaining = 0
|
this.sleepTimerRemaining = 0
|
||||||
this.sleepTimer = null
|
this.sleepTimer = null
|
||||||
this.sleepTimerSet = false
|
this.sleepTimerSet = false
|
||||||
|
this.sleepTimerType = null
|
||||||
},
|
},
|
||||||
incrementSleepTimer(amount) {
|
incrementSleepTimer(amount) {
|
||||||
if (!this.sleepTimerSet) return
|
if (!this.sleepTimerSet) return
|
||||||
@@ -202,17 +297,25 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.initialPlaybackRate = playbackRate
|
this.currentPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
this.playerHandler.seek(time)
|
this.playerHandler.seek(time)
|
||||||
},
|
},
|
||||||
|
playbackTimeUpdate(time) {
|
||||||
|
// When updating progress from another session
|
||||||
|
this.playerHandler.seek(time, false)
|
||||||
|
},
|
||||||
setCurrentTime(time) {
|
setCurrentTime(time) {
|
||||||
this.currentTime = time
|
this.currentTime = time
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setCurrentTime(time)
|
this.$refs.audioPlayer.setCurrentTime(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
||||||
|
this.checkChapterEnd(time)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setDuration(duration) {
|
setDuration(duration) {
|
||||||
this.totalDuration = duration
|
this.totalDuration = duration
|
||||||
@@ -263,6 +366,16 @@ export default {
|
|||||||
this.playerHandler.seek(e.seekTime)
|
this.playerHandler.seek(e.seekTime)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mediaSessionPreviousTrack() {
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.prevChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaSessionNextTrack() {
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.nextChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
updateMediaSessionPlaybackState() {
|
updateMediaSessionPlaybackState() {
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||||
@@ -275,7 +388,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||||
const artwork = [
|
const artwork = [
|
||||||
{
|
{
|
||||||
src: coverImageSrc
|
src: coverImageSrc
|
||||||
@@ -296,41 +409,44 @@ export default {
|
|||||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
// navigator.mediaSession.setActionHandler('previoustrack')
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||||
// navigator.mediaSession.setActionHandler('nexttrack')
|
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Media session not available')
|
console.warn('Media session not available')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||||
var chunks = data.chunks
|
if (!data.numSegments) return
|
||||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
var chunks = data.chunks
|
||||||
if (this.$refs.audioPlayer) {
|
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
if (this.$refs.audioPlayer) {
|
||||||
} else {
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
console.error('No Audio Ref')
|
} else {
|
||||||
|
console.error('No Audio Ref')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sessionOpen(session) {
|
sessionOpen(session) {
|
||||||
|
// For opening session on init (temporarily unused)
|
||||||
this.$store.commit('setMediaPlaying', {
|
this.$store.commit('setMediaPlaying', {
|
||||||
libraryItem: session.libraryItem,
|
libraryItem: session.libraryItem,
|
||||||
episodeId: session.episodeId
|
episodeId: session.episodeId
|
||||||
})
|
})
|
||||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||||
},
|
},
|
||||||
streamOpen(session) {
|
streamOpen(session) {
|
||||||
console.log(`[StreamContainer] Stream session open`, session)
|
console.log(`[MediaPlayerContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
console.log(`[STREAM-CONTAINER] Stream Ready`)
|
console.log(`[MediaPlayerContainer] Stream Ready`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setStreamReady()
|
this.$refs.audioPlayer.setStreamReady()
|
||||||
} else {
|
} else {
|
||||||
@@ -340,7 +456,7 @@ export default {
|
|||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -356,9 +472,33 @@ export default {
|
|||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
playNextItemInQueue() {
|
||||||
|
if (this.hasNextItemInQueue) {
|
||||||
|
this.playQueueItem({ index: this.currentPlayerQueueIndex + 1 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {{ index: number }} payload
|
||||||
|
*/
|
||||||
|
playQueueItem(payload) {
|
||||||
|
if (payload?.index === undefined) {
|
||||||
|
console.error('playQueueItem: No index provided')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.playerQueueItems[payload.index]) {
|
||||||
|
console.error('playQueueItem: No item found at index', payload.index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = this.playerQueueItems[payload.index]
|
||||||
|
this.playLibraryItem({
|
||||||
|
libraryItemId: item.libraryItemId,
|
||||||
|
episodeId: item.episodeId || null,
|
||||||
|
queueItems: this.playerQueueItems
|
||||||
|
})
|
||||||
|
},
|
||||||
async playLibraryItem(payload) {
|
async playLibraryItem(payload) {
|
||||||
var libraryItemId = payload.libraryItemId
|
const libraryItemId = payload.libraryItemId
|
||||||
var episodeId = payload.episodeId || null
|
const episodeId = payload.episodeId || null
|
||||||
|
|
||||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||||
@@ -369,20 +509,25 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||||
console.error('Failed to fetch full item', error)
|
console.error('Failed to fetch full item', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!libraryItem) return
|
if (!libraryItem) return
|
||||||
|
|
||||||
this.$store.commit('setMediaPlaying', {
|
this.$store.commit('setMediaPlaying', {
|
||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId,
|
||||||
|
queueItems: payload.queueItems || []
|
||||||
})
|
})
|
||||||
|
// Set cover aspect ratio for this item's library since the library may change
|
||||||
|
this.coverAspectRatio = this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
@@ -390,17 +535,28 @@ export default {
|
|||||||
showFailedProgressSyncs() {
|
showFailedProgressSyncs() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
|
},
|
||||||
|
sessionClosedEvent(sessionId) {
|
||||||
|
if (this.playerHandler.currentSessionId === sessionId) {
|
||||||
|
console.log('sessionClosedEvent closing current session', sessionId)
|
||||||
|
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
||||||
|
this.$store.commit('setMediaPlaying', null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('playback-seek', this.seek)
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$on('play-queue-item', this.playQueueItem)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('playback-seek', this.seek)
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$off('play-queue-item', this.playQueueItem)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
@@ -408,7 +564,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#streamContainer {
|
#mediaPlayerContainer {
|
||||||
box-shadow: 0px -6px 8px #1111113f;
|
box-shadow: 0px -6px 8px #1111113f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<slot name="header-prefix"></slot>
|
||||||
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
|
<slot name="header-items"></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
||||||
|
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
headerText: String,
|
||||||
|
description: String,
|
||||||
|
note: String
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#settings-description a {
|
||||||
|
color: rgb(96 165 250);
|
||||||
|
}
|
||||||
|
#settings-description a:hover {
|
||||||
|
color: rgb(147 197 253);
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
#settings-description code {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgb(82, 82, 82);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,86 +1,135 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
|
||||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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'">
|
||||||
<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 xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<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: 0.9rem">Home</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||||
|
|
||||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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'">
|
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" 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="isPodcastLatestPage ? '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">
|
<span class="material-symbols text-2xl"></span>
|
||||||
<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: 0.9rem">Library</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||||
|
|
||||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Series</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||||
|
|
||||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/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'">
|
<nuxt-link v-if="isBookLibrary" :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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-icons-outlined">collections_bookmark</span>
|
<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: 0.9rem">Collections</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/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 class="w-6 h-6" viewBox="0 0 24 24">
|
<span class="material-symbols text-2xl"></span>
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Authors</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||||
|
|
||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" 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="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<icons-podcast-svg class="w-6 h-6" />
|
<span class="material-symbols text-2.5xl"></span>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-icons text-2xl">warning</span>
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||||
|
|
||||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
</nuxt-link>
|
||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
|
|
||||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" 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="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-symbols text-2xl"></span>
|
||||||
|
|
||||||
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||||
|
|
||||||
|
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" 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="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-symbols text-2xl"></span>
|
||||||
|
|
||||||
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
||||||
|
|
||||||
|
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="abs-icons icon-podcast text-xl"></span>
|
||||||
|
|
||||||
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" 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="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-symbols text-xl">album</span>
|
||||||
|
|
||||||
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||||
|
|
||||||
|
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" 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="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-symbols text-2xl"></span>
|
||||||
|
|
||||||
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPodcastDownloadQueuePage" 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-symbols text-2xl">warning</span>
|
||||||
|
|
||||||
|
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
||||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -117,12 +166,27 @@ export default {
|
|||||||
currentLibraryMediaType() {
|
currentLibraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'book'
|
||||||
|
},
|
||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.currentLibraryMediaType === 'podcast'
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusicLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'music'
|
||||||
|
},
|
||||||
|
isPodcastDownloadQueuePage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-download-queue'
|
||||||
|
},
|
||||||
isPodcastSearchPage() {
|
isPodcastSearchPage() {
|
||||||
return this.$route.name === 'library-library-podcast-search'
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
},
|
},
|
||||||
|
isPodcastLatestPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
|
},
|
||||||
|
isMusicAlbumsPage() {
|
||||||
|
return this.paramId === 'albums'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -132,6 +196,15 @@ export default {
|
|||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.$route.name === 'library-library-authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
},
|
||||||
|
isNarratorsPage() {
|
||||||
|
return this.$route.name === 'library-library-narrators'
|
||||||
|
},
|
||||||
|
isPlaylistsPage() {
|
||||||
|
return this.paramId === 'playlists'
|
||||||
|
},
|
||||||
|
isStatsPage() {
|
||||||
|
return this.$route.name === 'library-library-stats'
|
||||||
|
},
|
||||||
libraryBookshelfPage() {
|
libraryBookshelfPage() {
|
||||||
return this.$route.name === 'library-library-bookshelf-id'
|
return this.$route.name === 'library-library-bookshelf-id'
|
||||||
},
|
},
|
||||||
@@ -157,18 +230,27 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
currentVersionChangelog() {
|
|
||||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
|
||||||
},
|
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
showPlaylists() {
|
||||||
|
return this.$store.state.libraries.numUserPlaylists > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickChangelog(){
|
clickChangelog() {
|
||||||
this.showChangelogModal = true
|
this.showChangelogModal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#siderail-buttons-container {
|
||||||
|
max-height: calc(100vh - 64px - 48px);
|
||||||
|
}
|
||||||
|
#siderail-buttons-container.player-open {
|
||||||
|
max-height: calc(100vh - 64px - 48px - 160px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link :to="`/author/${author.id}`">
|
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<!-- Image or placeholder -->
|
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<covers-author-image :author="author" />
|
<!-- Image or placeholder -->
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
|
||||||
<!-- Author name & num books overlay -->
|
<!-- Author name & num books overlay -->
|
||||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
<div cy-id="textInline" v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- Search icon btn -->
|
||||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
<div cy-id="match" v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||||
<span class="material-icons text-lg">search</span>
|
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
</div>
|
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">search</span>
|
||||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
</ui-tooltip>
|
||||||
<span class="material-icons text-lg">edit</span>
|
</div>
|
||||||
</div>
|
<div cy-id="edit" v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||||
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
|
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">edit</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- Loading spinner -->
|
||||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
<widgets-loading-spinner size="" />
|
<widgets-loading-spinner size="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div cy-id="nameBelow" v-show="nameBelow" class="w-full py-1e px-2e">
|
||||||
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
</nuxt-link>
|
||||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -39,12 +45,14 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: {
|
||||||
sizeMultiplier: {
|
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1
|
default: 192
|
||||||
},
|
},
|
||||||
nameBelow: Boolean
|
nameBelow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +61,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
cardWidth() {
|
||||||
|
return this.width || this.cardHeight * 0.8
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
@@ -73,6 +87,15 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -88,17 +111,22 @@ export default {
|
|||||||
if (this.asin) payload.asin = this.asin
|
if (this.asin) payload.asin = this.asin
|
||||||
else payload.q = this.name
|
else payload.q = this.name
|
||||||
|
|
||||||
|
payload.region = 'us'
|
||||||
|
if (this.libraryProvider.startsWith('audible.')) {
|
||||||
|
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||||
|
}
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!response) {
|
if (!response) {
|
||||||
this.$toast.error('Author not found')
|
this.$toast.error(`Author ${this.name} not found`)
|
||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||||
else this.$toast.success('Author was updated (no image found)')
|
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No updates were made for Author')
|
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||||
}
|
}
|
||||||
this.searching = false
|
this.searching = false
|
||||||
},
|
},
|
||||||
@@ -113,4 +141,4 @@ export default {
|
|||||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,6 +24,9 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
name() {
|
name() {
|
||||||
return this.author.name
|
return this.author.name
|
||||||
|
},
|
||||||
|
numBooks() {
|
||||||
|
return this.author.numBooks
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
@@ -33,9 +37,9 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.authorSearchCardContent {
|
.authorSearchCardContent {
|
||||||
width: calc(100% - 80px);
|
width: calc(100% - 80px);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||||
<div class="w-full bg-primary">
|
<div class="w-full bg-primary">
|
||||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||||
|
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||||
@@ -12,13 +13,13 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||||
<p class="leading-3 text-xs text-gray-400">
|
<p class="leading-3 text-xs text-gray-400">
|
||||||
{{ series.series }}<span v-if="series.volumeNumber"> #{{ series.volumeNumber }}</span>
|
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,8 +28,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="px-4 flex-grow">
|
<div v-else class="px-4 flex-grow">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator v-if="book.explicit" /></div>
|
||||||
|
</h1>
|
||||||
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +54,8 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
isPodcast: Boolean,
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
currentBookDuration: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -60,12 +64,27 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCovers() {
|
bookCovers() {
|
||||||
return this.book.covers ? this.book.covers || [] : []
|
return this.book.covers || []
|
||||||
|
},
|
||||||
|
bookDuration() {
|
||||||
|
return (this.book.duration || 0) * 60
|
||||||
|
},
|
||||||
|
bookDurationComparison() {
|
||||||
|
if (!this.book.duration || !this.currentBookDuration) return ''
|
||||||
|
const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60)
|
||||||
|
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
||||||
|
if (differenceInMinutes < 0) {
|
||||||
|
differenceInMinutes = Math.abs(differenceInMinutes)
|
||||||
|
return this.$getString('LabelDurationComparisonLonger', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||||
|
} else if (differenceInMinutes > 0) {
|
||||||
|
return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||||
|
}
|
||||||
|
return this.$strings.LabelDurationComparisonExactMatch
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
selectMatch() {
|
selectMatch() {
|
||||||
var book = { ...this.book }
|
const book = { ...this.book }
|
||||||
book.cover = this.selectedCover
|
book.cover = this.selectedCover
|
||||||
this.$emit('select', book)
|
this.$emit('select', book)
|
||||||
},
|
},
|
||||||
@@ -77,4 +96,4 @@ export default {
|
|||||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
|
<span class="material-symbols text-2xl text-gray-200">category</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ genre }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
genre: String,
|
||||||
|
numItems: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tagSearchCardContent {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
<div class="rounded-sm h-full relative" :style="{ width: cardWidth + 'px', height: cardHeight + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||||
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="cardWidth" :height="cardHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" 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` }">
|
<div v-if="hasValidCovers" 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>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||||
</div>
|
</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 z-40">
|
<div class="absolute z-10 top-1.5e right-1.5e rounded-md leading-3e p-1e font-semibold text-white flex items-center justify-center" :style="{ fontSize: 0.8 + 'em' }" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,34 +23,26 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
width: {
|
width: Number,
|
||||||
|
height: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 192
|
||||||
},
|
}
|
||||||
isCategorized: Boolean,
|
|
||||||
bookCoverAspectRatio: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isHovering: false
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
seriesId() {
|
bookCoverAspectRatio() {
|
||||||
return this.groupEncode
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
labelFontSize() {
|
cardWidth() {
|
||||||
if (this.coverWidth < 160) return 0.75
|
return this.width || this.cardHeight * 2
|
||||||
return 0.875
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
@@ -70,29 +54,10 @@ export default {
|
|||||||
return this._group.type
|
return this._group.type
|
||||||
},
|
},
|
||||||
groupTo() {
|
groupTo() {
|
||||||
if (this.groupType === 'series') {
|
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||||
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
|
||||||
} else if (this.groupType === 'collection') {
|
|
||||||
return `/collection/${this._group.id}`
|
|
||||||
} else {
|
|
||||||
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
squareAspectRatio() {
|
|
||||||
return this.bookCoverAspectRatio === 1
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.width * this.bookCoverAspectRatio
|
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
return this.width / baseSize
|
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
@@ -123,4 +88,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,15 +2,9 @@
|
|||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,10 +15,7 @@ export default {
|
|||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
}
|
||||||
search: String,
|
|
||||||
matchKey: String,
|
|
||||||
matchText: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -58,21 +49,6 @@ export default {
|
|||||||
authorName() {
|
authorName() {
|
||||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
return this.mediaMetadata.authorName || 'Unknown'
|
return this.mediaMetadata.authorName || 'Unknown'
|
||||||
},
|
|
||||||
matchHtml() {
|
|
||||||
if (!this.matchText || !this.search) return ''
|
|
||||||
if (this.matchKey === 'subtitle') return ''
|
|
||||||
|
|
||||||
// This used to highlight the part of the search found
|
|
||||||
// but with removing commas periods etc this is no longer plausible
|
|
||||||
const html = this.matchText
|
|
||||||
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
|
||||||
return `${html}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
@@ -88,4 +64,4 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center px-1 overflow-hidden">
|
||||||
|
<div class="w-8 flex items-center justify-center">
|
||||||
|
<span v-if="isFinished" :class="taskIconStatus" class="material-symbols text-base">{{ actionIcon }}</span>
|
||||||
|
<widgets-loading-spinner v-else />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 taskRunningCardContent">
|
||||||
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
|
|
||||||
|
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||||
|
|
||||||
|
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||||
|
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
||||||
|
</div>
|
||||||
|
<ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
task: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cancelingScan: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.task.title || 'No Title'
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.task.description || ''
|
||||||
|
},
|
||||||
|
details() {
|
||||||
|
return this.task.details || 'Unknown'
|
||||||
|
},
|
||||||
|
isFinished() {
|
||||||
|
return !!this.task.isFinished
|
||||||
|
},
|
||||||
|
isFailed() {
|
||||||
|
return !!this.task.isFailed
|
||||||
|
},
|
||||||
|
isSuccess() {
|
||||||
|
return this.isFinished && !this.isFailed
|
||||||
|
},
|
||||||
|
failedMessage() {
|
||||||
|
return this.task.error || ''
|
||||||
|
},
|
||||||
|
action() {
|
||||||
|
return this.task.action || ''
|
||||||
|
},
|
||||||
|
actionIcon() {
|
||||||
|
if (this.isFailed) {
|
||||||
|
return 'error'
|
||||||
|
} else if (this.isSuccess) {
|
||||||
|
return 'done'
|
||||||
|
}
|
||||||
|
switch (this.action) {
|
||||||
|
case 'download-podcast-episode':
|
||||||
|
return 'cloud_download'
|
||||||
|
case 'encode-m4b':
|
||||||
|
return 'sync'
|
||||||
|
default:
|
||||||
|
return 'settings'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
taskIconStatus() {
|
||||||
|
if (this.isFinished && this.isFailed) {
|
||||||
|
return 'text-red-500'
|
||||||
|
}
|
||||||
|
if (this.isFinished && !this.isFailed) {
|
||||||
|
return 'text-green-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
isLibraryScan() {
|
||||||
|
return this.action === 'library-scan' || this.action === 'library-match-all'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelScan() {
|
||||||
|
const libraryId = this.task?.data?.libraryId
|
||||||
|
if (!libraryId) {
|
||||||
|
console.error('No library id in library-scan task', this.task)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cancelingScan = true
|
||||||
|
this.$root.socket.emit('cancel_scan', libraryId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.taskRunningCardContent {
|
||||||
|
width: calc(100% - 84px);
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
|
<span class="text-base text-white text-opacity-80 font-mono material-symbols">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!uploadSuccess && !uploadFailed">
|
<template v-if="!uploadSuccess && !uploadFailed">
|
||||||
@@ -15,41 +15,54 @@
|
|||||||
|
|
||||||
<div class="flex my-2 -mx-2">
|
<div class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" label="Author" />
|
<div v-if="!isPodcast" class="flex items-end">
|
||||||
|
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||||
|
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||||
|
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||||
|
<span class="text-base text-white text-opacity-80 font-mono material-symbols">sync</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
<p class="px-1 text-sm font-semibold">
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
{{ $strings.LabelDirectory }}
|
||||||
|
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||||
|
</p>
|
||||||
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" label="Series" note="(optional)" />
|
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
<label class="px-1 text-sm font-semibold">
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
{{ $strings.LabelDirectory }}
|
||||||
|
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||||
|
</label>
|
||||||
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
|
<tables-uploaded-files-table :files="item.itemFiles" :title="$strings.HeaderItemFiles" class="mt-8" />
|
||||||
<tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
|
<tables-uploaded-files-table v-if="item.otherFiles.length" :title="$strings.HeaderOtherFiles" :files="item.otherFiles" />
|
||||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
|
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
||||||
</template>
|
</template>
|
||||||
<widgets-alert v-if="uploadSuccess" type="success">
|
<widgets-alert v-if="uploadSuccess" type="success">
|
||||||
<p class="text-base">Successfully Uploaded!</p>
|
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemSuccess }}</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
<widgets-alert v-if="uploadFailed" type="error">
|
<widgets-alert v-if="uploadFailed" type="error">
|
||||||
<p class="text-base">Failed to upload</p>
|
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||||
<ui-loading-indicator text="Uploading..." />
|
<ui-loading-indicator :text="nonInteractionLabel" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,7 +77,8 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
mediaType: String,
|
mediaType: String,
|
||||||
processing: Boolean
|
processing: Boolean,
|
||||||
|
provider: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,7 +90,8 @@ export default {
|
|||||||
error: '',
|
error: '',
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
uploadSuccess: false
|
uploadSuccess: false,
|
||||||
|
isFetchingMetadata: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -87,12 +102,19 @@ export default {
|
|||||||
if (!this.itemData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
if (this.isPodcast) return this.itemData.title
|
if (this.isPodcast) return this.itemData.title
|
||||||
|
|
||||||
if (this.itemData.series && this.itemData.author) {
|
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||||
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map((part) => this.$sanitizeFilename(part))
|
||||||
} else if (this.itemData.author) {
|
|
||||||
return Path.join(this.itemData.author, this.itemData.title)
|
return Path.join(...cleanedOutputPathParts)
|
||||||
} else {
|
},
|
||||||
return this.itemData.title
|
isNonInteractable() {
|
||||||
|
return this.isUploading || this.isFetchingMetadata
|
||||||
|
},
|
||||||
|
nonInteractionLabel() {
|
||||||
|
if (this.isUploading) {
|
||||||
|
return this.$strings.MessageUploading
|
||||||
|
} else if (this.isFetchingMetadata) {
|
||||||
|
return this.$strings.LabelFetchingMetadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -105,15 +127,49 @@ export default {
|
|||||||
titleUpdated() {
|
titleUpdated() {
|
||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
|
async fetchMetadata() {
|
||||||
|
if (!this.itemData.title.trim().length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFetchingMetadata = true
|
||||||
|
this.error = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchQueryString = new URLSearchParams({
|
||||||
|
title: this.itemData.title,
|
||||||
|
author: this.itemData.author,
|
||||||
|
provider: this.provider
|
||||||
|
})
|
||||||
|
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
|
||||||
|
|
||||||
|
if (bestCandidate) {
|
||||||
|
this.itemData = {
|
||||||
|
...this.itemData,
|
||||||
|
title: bestCandidate.title,
|
||||||
|
author: bestCandidate.author,
|
||||||
|
series: (bestCandidate.series || [])[0]?.series
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed', e)
|
||||||
|
this.error = this.$strings.ErrorUploadFetchMetadataAPI
|
||||||
|
} finally {
|
||||||
|
this.isFetchingMetadata = false
|
||||||
|
}
|
||||||
|
},
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.itemData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = 'Must have a title'
|
this.error = this.$strings.ErrorUploadLacksTitle
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.item.index,
|
index: this.item.index,
|
||||||
|
directory: this.directory,
|
||||||
...this.itemData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
@@ -127,4 +183,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||||
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || ' ' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
index: Number,
|
||||||
|
width: Number,
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
albumMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
album: null,
|
||||||
|
isSelectionMode: false,
|
||||||
|
selected: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.width || this.coverHeight
|
||||||
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
cardHeight() {
|
||||||
|
return this.coverHeight + this.bottomTextHeight
|
||||||
|
},
|
||||||
|
bottomTextHeight() {
|
||||||
|
if (!this.isAlternativeBookshelfView) return 0
|
||||||
|
const lineHeight = 1.5
|
||||||
|
const remSize = 16
|
||||||
|
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||||
|
const titleHeight = this.labelFontSize * baseHeight
|
||||||
|
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
||||||
|
return titleHeight + paddingHeight
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
coverSrc() {
|
||||||
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
|
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||||
|
},
|
||||||
|
labelFontSize() {
|
||||||
|
if (this.width < 160) return 0.75
|
||||||
|
return 0.9
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.album ? this.album.title : ''
|
||||||
|
},
|
||||||
|
artist() {
|
||||||
|
return this.album ? this.album.artist : ''
|
||||||
|
},
|
||||||
|
store() {
|
||||||
|
return this.$store || this.$nuxt.$store
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setEntity(album) {
|
||||||
|
this.album = album
|
||||||
|
},
|
||||||
|
setSelectionMode(val) {
|
||||||
|
this.isSelectionMode = val
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickCard() {
|
||||||
|
if (!this.album) return
|
||||||
|
// const router = this.$router || this.$nuxt.$router
|
||||||
|
// router.push(`/album/${this.$encode(this.title)}`)
|
||||||
|
},
|
||||||
|
clickEdit() {
|
||||||
|
this.$emit('edit', this.album)
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
// destroy the vue listeners, etc
|
||||||
|
this.$destroy()
|
||||||
|
|
||||||
|
// remove the element from the DOM
|
||||||
|
if (this.$el && this.$el.parentNode) {
|
||||||
|
this.$el.parentNode.removeChild(this.$el)
|
||||||
|
} else if (this.$el && this.$el.remove) {
|
||||||
|
this.$el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.albumMount) {
|
||||||
|
this.setEntity(this.albumMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
<covers-collection-cover ref="cover" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
||||||
|
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,12 +30,19 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: {
|
||||||
bookCoverAspectRatio: Number,
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
}
|
},
|
||||||
|
collectionMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
isTag: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -41,13 +53,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
||||||
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.875
|
return 0.9
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
return this.width / 240
|
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.collection ? this.collection.name : ''
|
return this.collection ? this.collection.name : ''
|
||||||
@@ -63,7 +83,13 @@ export default {
|
|||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
const constants = this.$constants || this.$nuxt.$constants
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
return this.collection ? this.collection.rssFeed : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -99,6 +125,10 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
if (this.collectionMount) {
|
||||||
|
this.setEntity(this.collectionMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
<covers-playlist-cover ref="cover" :items="items" :width="cardWidth" :height="coverHeight" />
|
||||||
|
</div>
|
||||||
|
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
||||||
|
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 -bottom-6e left-0 right-0 mx-auto h-6e rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||||
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
index: Number,
|
||||||
|
width: Number,
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
playlistMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
playlist: null,
|
||||||
|
isSelectionMode: false,
|
||||||
|
selected: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.width || this.coverHeight
|
||||||
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
labelFontSize() {
|
||||||
|
if (this.width < 160) return 0.75
|
||||||
|
return 0.9
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.playlist ? this.playlist.name : ''
|
||||||
|
},
|
||||||
|
items() {
|
||||||
|
return this.playlist ? this.playlist.items || [] : []
|
||||||
|
},
|
||||||
|
store() {
|
||||||
|
return this.$store || this.$nuxt.$store
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setEntity(playlist) {
|
||||||
|
this.playlist = playlist
|
||||||
|
},
|
||||||
|
setSelectionMode(val) {
|
||||||
|
this.isSelectionMode = val
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickCard() {
|
||||||
|
if (!this.playlist) return
|
||||||
|
var router = this.$router || this.$nuxt.$router
|
||||||
|
router.push(`/playlist/${this.playlist.id}`)
|
||||||
|
},
|
||||||
|
clickEdit() {
|
||||||
|
this.$emit('edit', this.playlist)
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
// destroy the vue listeners, etc
|
||||||
|
this.$destroy()
|
||||||
|
|
||||||
|
// remove the element from the DOM
|
||||||
|
if (this.$el && this.$el.parentNode) {
|
||||||
|
this.$el.parentNode.removeChild(this.$el)
|
||||||
|
} else if (this.$el && this.$el.remove) {
|
||||||
|
this.$el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.playlistMount) {
|
||||||
|
this.setEntity(this.playlistMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||||
|
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||||
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
|
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||||
|
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span cy-id="rssFeedMarker" v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||||
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
|
||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||||
|
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,18 +36,20 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: {
|
||||||
bookCoverAspectRatio: Number,
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
isCategorized: Boolean,
|
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
sortingIgnorePrefix: Boolean
|
sortingIgnorePrefix: Boolean,
|
||||||
|
orderBy: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -52,32 +61,62 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
||||||
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.875
|
return 0.9
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
return this.width / 240
|
|
||||||
},
|
},
|
||||||
seriesId() {
|
seriesId() {
|
||||||
return this.series ? this.series.id : ''
|
return this.series?.id || ''
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series?.name || ''
|
||||||
},
|
},
|
||||||
nameIgnorePrefix() {
|
nameIgnorePrefix() {
|
||||||
return this.series ? this.series.nameIgnorePrefix : ''
|
return this.series?.nameIgnorePrefix || ''
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
|
||||||
return this.title
|
return this.title || '\u00A0'
|
||||||
|
},
|
||||||
|
displaySortLine() {
|
||||||
|
switch (this.orderBy) {
|
||||||
|
case 'addedAt':
|
||||||
|
return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])
|
||||||
|
case 'totalDuration':
|
||||||
|
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||||
|
case 'lastBookUpdated':
|
||||||
|
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
|
||||||
|
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
||||||
|
case 'lastBookAdded':
|
||||||
|
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
|
||||||
|
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series?.books || []
|
||||||
},
|
},
|
||||||
addedAt() {
|
addedAt() {
|
||||||
return this.series ? this.series.addedAt : 0
|
return this.series?.addedAt || 0
|
||||||
|
},
|
||||||
|
totalDuration() {
|
||||||
|
return this.series?.totalDuration || 0
|
||||||
},
|
},
|
||||||
seriesBookProgress() {
|
seriesBookProgress() {
|
||||||
return this.books
|
return this.books
|
||||||
@@ -89,6 +128,18 @@ export default {
|
|||||||
seriesBooksFinished() {
|
seriesBooksFinished() {
|
||||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
},
|
},
|
||||||
|
hasSeriesBookInProgress() {
|
||||||
|
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||||
|
},
|
||||||
|
seriesPercentInProgress() {
|
||||||
|
if (!this.books.length) return 0
|
||||||
|
let progressPercent = 0
|
||||||
|
this.seriesBookProgress.forEach((progress) => {
|
||||||
|
progressPercent += progress.isFinished ? 1 : progress.progress || 0
|
||||||
|
})
|
||||||
|
progressPercent /= this.books.length
|
||||||
|
return Math.min(1, Math.max(0, progressPercent))
|
||||||
|
},
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.books.length === this.seriesBooksFinished.length
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
},
|
},
|
||||||
@@ -107,7 +158,10 @@ export default {
|
|||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
const constants = this.$constants || this.$nuxt.$constants
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
return this.series?.rssFeed
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
||||||
|
<div cy-id="card" :style="{ width: cardWidth + 'px', height: cardHeight + 'px', fontSize: sizeMultiplier + 'rem' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
|
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
||||||
|
<span class="material-symbols text-[10em]"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Narrator name & num books overlay -->
|
||||||
|
<div class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
||||||
|
<p cy-id="name" class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||||
|
<p cy-id="numBooks" class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
narrator: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
width: Number,
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cardWidth() {
|
||||||
|
return this.cardHeight * 1.5
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.narrator?.name || ''
|
||||||
|
},
|
||||||
|
numBooks() {
|
||||||
|
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
|
<span class="material-symbols text-2xl text-gray-200"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ narrator }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
narrator: String,
|
||||||
|
numBooks: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.narratorSearchCardContent {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full border border-white border-opacity-10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary bg-opacity-25' : 'bg-error bg-opacity-5'">
|
||||||
|
<div class="flex flex-wrap items-center">
|
||||||
|
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">{{ this.$strings.ButtonFireOnTest }}</ui-btn>
|
||||||
|
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">{{ this.$strings.ButtonFireAndFail }}</ui-btn>
|
||||||
|
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
|
||||||
|
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">{{ this.$strings.ButtonTest }}</ui-btn>
|
||||||
|
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">{{ this.$strings.ButtonEnable }}</ui-btn>
|
||||||
|
|
||||||
|
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
|
||||||
|
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
|
||||||
|
</div>
|
||||||
|
<div class="pt-4">
|
||||||
|
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
|
||||||
|
|
||||||
|
<p v-if="lastFiredAt && lastAttemptFailed" class="text-red-300 text-xs">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>
|
||||||
|
<p v-else-if="lastFiredAt" class="text-gray-400 text-xs">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
notification: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sendingTest: false,
|
||||||
|
enabling: false,
|
||||||
|
deleting: false,
|
||||||
|
testing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
eventName() {
|
||||||
|
return this.notification ? this.notification.eventName : null
|
||||||
|
},
|
||||||
|
lastFiredAt() {
|
||||||
|
return this.notification ? this.notification.lastFiredAt : null
|
||||||
|
},
|
||||||
|
lastAttemptFailed() {
|
||||||
|
return this.notification ? this.notification.lastAttemptFailed : null
|
||||||
|
},
|
||||||
|
numConsecutiveFailedAttempts() {
|
||||||
|
return this.notification ? this.notification.numConsecutiveFailedAttempts : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// For testing using the onTest event
|
||||||
|
fireTestEventAndFail() {
|
||||||
|
this.fireTestEvent(true)
|
||||||
|
},
|
||||||
|
fireTestEventAndSucceed() {
|
||||||
|
this.fireTestEvent(false)
|
||||||
|
},
|
||||||
|
fireTestEvent(intentionallyFail = false) {
|
||||||
|
this.testing = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.testing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rapidFireTestEvents() {
|
||||||
|
this.testing = true
|
||||||
|
var numFired = 0
|
||||||
|
var interval = setInterval(() => {
|
||||||
|
this.fireTestEvent()
|
||||||
|
numFired++
|
||||||
|
if (numFired > 25) {
|
||||||
|
this.testing = false
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
// End testing functions
|
||||||
|
sendTestClick() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmNotificationTestTrigger,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.sendTest()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
sendTest() {
|
||||||
|
this.sendingTest = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/notifications/${this.notification.id}/test`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.sendingTest = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
enableNotification() {
|
||||||
|
this.enabling = true
|
||||||
|
const payload = {
|
||||||
|
id: this.notification.id,
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/notifications/${this.notification.id}`, payload)
|
||||||
|
.then((updatedSettings) => {
|
||||||
|
this.$emit('update', updatedSettings)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update notification', error)
|
||||||
|
this.$toast.error(this.$strings.ToastNotificationUpdateFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.enabling = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteNotificationClick() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmDeleteNotification,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.deleteNotification()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
deleteNotification() {
|
||||||
|
this.deleting = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/notifications/${this.notification.id}`)
|
||||||
|
.then((updatedSettings) => {
|
||||||
|
this.$emit('update', updatedSettings)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastNotificationDeleteFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.deleting = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
editNotification() {
|
||||||
|
this.$emit('edit', this.notification)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
<div class="w-full h-16 bg-primary">
|
<div class="w-full h-16 bg-primary">
|
||||||
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
|
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||||
<p class="mb-1">{{ title }}</p>
|
<p class="mb-1">{{ title }}</p>
|
||||||
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||||
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||||
<p class="text-xs truncate text-blue-200">
|
<p class="text-xs truncate text-blue-200">
|
||||||
Folder: <span class="font-mono">{{ folderPath }}</span>
|
{{ $strings.LabelFolder }}: <span class="font-mono">{{ folderPath }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
},
|
},
|
||||||
folderPath() {
|
folderPath() {
|
||||||
if (!this.libraryFolderPath) return ''
|
if (!this.libraryFolderPath) return ''
|
||||||
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
|
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
|
||||||
},
|
},
|
||||||
detailsWidth() {
|
detailsWidth() {
|
||||||
return this.width - 85
|
return this.width - 85
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<div class="w-10 h-10 flex items-center justify-center">
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
<span class="material-icons text-2xl text-gray-200">local_offer</span>
|
<span class="material-symbols text-2xl text-gray-200">local_offer</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ tag }}</p>
|
<p class="truncate text-sm">{{ tag }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -12,7 +13,8 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
tag: String
|
tag: String,
|
||||||
|
numItems: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -26,9 +28,9 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.tagSearchCardContent {
|
.tagSearchCardContent {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(narrator, index) in narrators">
|
||||||
|
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publishedYear" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ publishedYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publisher" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicAlbum" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbum }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbumArtist }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicTrackPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicDiscPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="podcastType" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="capitalize">
|
||||||
|
{{ podcastType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="genres.length">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(genre, index) in genres">
|
||||||
|
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="tags.length">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(tag, index) in tags">
|
||||||
|
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="language" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ durationPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ sizePretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryId() {
|
||||||
|
return this.libraryItem.libraryId
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
audioFile() {
|
||||||
|
// Music track
|
||||||
|
return this.media.audioFile
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
podcastEpisodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
publishedYear() {
|
||||||
|
return this.mediaMetadata.publishedYear
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.mediaMetadata.genres || []
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.media.tags || []
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
return this.mediaMetadata.author || ''
|
||||||
|
},
|
||||||
|
authors() {
|
||||||
|
return this.mediaMetadata.authors || []
|
||||||
|
},
|
||||||
|
publisher() {
|
||||||
|
return this.mediaMetadata.publisher || ''
|
||||||
|
},
|
||||||
|
musicArtists() {
|
||||||
|
return this.mediaMetadata.artists || []
|
||||||
|
},
|
||||||
|
musicAlbum() {
|
||||||
|
return this.mediaMetadata.album || ''
|
||||||
|
},
|
||||||
|
musicAlbumArtist() {
|
||||||
|
return this.mediaMetadata.albumArtist || ''
|
||||||
|
},
|
||||||
|
musicTrackPretty() {
|
||||||
|
if (!this.mediaMetadata.trackNumber) return null
|
||||||
|
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||||
|
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||||
|
},
|
||||||
|
musicDiscPretty() {
|
||||||
|
if (!this.mediaMetadata.discNumber) return null
|
||||||
|
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||||
|
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||||
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.mediaMetadata.narrators || []
|
||||||
|
},
|
||||||
|
language() {
|
||||||
|
return this.mediaMetadata.language || null
|
||||||
|
},
|
||||||
|
durationPretty() {
|
||||||
|
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||||
|
|
||||||
|
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
||||||
|
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
||||||
|
return this.$elapsedPretty(this.duration)
|
||||||
|
},
|
||||||
|
duration() {
|
||||||
|
if (!this.tracks.length && !this.audioFile) return 0
|
||||||
|
return this.media.duration
|
||||||
|
},
|
||||||
|
totalPodcastDuration() {
|
||||||
|
if (!this.podcastEpisodes.length) return 0
|
||||||
|
let totalDuration = 0
|
||||||
|
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
||||||
|
return totalDuration
|
||||||
|
},
|
||||||
|
sizePretty() {
|
||||||
|
return this.$bytesPretty(this.media.size)
|
||||||
|
},
|
||||||
|
podcastType() {
|
||||||
|
return this.mediaMetadata.type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
@@ -10,46 +10,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 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" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in selectItems">
|
<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" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
|
||||||
<span class="material-icons">arrow_right</span>
|
<!-- selected checkmark icon -->
|
||||||
</div>
|
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||||
</li>
|
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
|
||||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
|
||||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
|
||||||
<span class="material-icons">arrow_left</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-normal ml-3 block truncate">Back</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<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.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">{{ item.text }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,97 +36,15 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: String
|
value: String,
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false
|
||||||
sublist: null,
|
|
||||||
bookItems: [
|
|
||||||
{
|
|
||||||
text: 'All',
|
|
||||||
value: 'all'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Genre',
|
|
||||||
value: 'genres',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Tag',
|
|
||||||
value: 'tags',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Series',
|
|
||||||
value: 'series',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Authors',
|
|
||||||
value: 'authors',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Narrator',
|
|
||||||
value: 'narrators',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Language',
|
|
||||||
value: 'languages',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Progress',
|
|
||||||
value: 'progress',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Missing',
|
|
||||||
value: 'missing',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Issues',
|
|
||||||
value: 'issues',
|
|
||||||
sublist: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'RSS Feed Open',
|
|
||||||
value: 'feed-open',
|
|
||||||
sublist: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
podcastItems: [
|
|
||||||
{
|
|
||||||
text: 'All',
|
|
||||||
value: 'all'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Genre',
|
|
||||||
value: 'genres',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Tag',
|
|
||||||
value: 'tags',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Issues',
|
|
||||||
value: 'issues',
|
|
||||||
sublist: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
showMenu(newVal) {
|
|
||||||
if (!newVal) {
|
|
||||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
|
||||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -163,81 +56,10 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isPodcast() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
|
||||||
},
|
|
||||||
selectItems() {
|
|
||||||
if (this.isPodcast) return this.podcastItems
|
|
||||||
return this.bookItems
|
|
||||||
},
|
|
||||||
selectedItemSublist() {
|
|
||||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
|
||||||
},
|
|
||||||
selectedText() {
|
selectedText() {
|
||||||
if (!this.selected) return ''
|
if (!this.selected) return ''
|
||||||
var parts = this.selected.split('.')
|
const filter = this.items.find((i) => i.value === this.selected)
|
||||||
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
return filter ? filter.text : ''
|
||||||
var filterValue = null
|
|
||||||
if (parts.length > 1) {
|
|
||||||
var decoded = this.$decode(parts[1])
|
|
||||||
if (decoded.startsWith('aut_')) {
|
|
||||||
var author = this.authors.find((au) => au.id == decoded)
|
|
||||||
if (author) filterValue = author.name
|
|
||||||
} else if (decoded.startsWith('ser_')) {
|
|
||||||
var series = this.series.find((se) => se.id == decoded)
|
|
||||||
if (series) filterValue = series.name
|
|
||||||
} else {
|
|
||||||
filterValue = decoded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filterName && filterValue) {
|
|
||||||
return `${filterName.text}: ${filterValue}`
|
|
||||||
} else if (filterName) {
|
|
||||||
return filterName.text
|
|
||||||
} else if (filterValue) {
|
|
||||||
return filterValue
|
|
||||||
} else {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
return this.filterData.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.filterData.tags || []
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.filterData.series || []
|
|
||||||
},
|
|
||||||
authors() {
|
|
||||||
return this.filterData.authors || []
|
|
||||||
},
|
|
||||||
narrators() {
|
|
||||||
return this.filterData.narrators || []
|
|
||||||
},
|
|
||||||
languages() {
|
|
||||||
return this.filterData.languages || []
|
|
||||||
},
|
|
||||||
progress() {
|
|
||||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
|
||||||
},
|
|
||||||
missing() {
|
|
||||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
|
||||||
},
|
|
||||||
sublistItems() {
|
|
||||||
return (this[this.sublist] || []).map((item) => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
return {
|
|
||||||
text: item,
|
|
||||||
value: this.$encode(item)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
text: item.name,
|
|
||||||
value: this.$encode(item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
@@ -250,18 +72,9 @@ export default {
|
|||||||
this.$nextTick(() => this.$emit('change', 'all'))
|
this.$nextTick(() => this.$emit('change', 'all'))
|
||||||
},
|
},
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
if (!this.selectedItemSublist) this.sublist = null
|
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
},
|
},
|
||||||
clickedSublistOption(item) {
|
|
||||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
|
||||||
},
|
|
||||||
clickedOption(option) {
|
clickedOption(option) {
|
||||||
if (option.sublist) {
|
|
||||||
this.sublist = option.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var val = option.value
|
var val = option.value
|
||||||
if (this.selected === val) {
|
if (this.selected === val) {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
|
|||||||
@@ -1,52 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sm:w-80 w-full relative">
|
<div class="">
|
||||||
<form @submit.prevent="submitSearch">
|
<div class="w-full relative sm:w-80">
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<form @submit.prevent="submitSearch">
|
||||||
</form>
|
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
</form>
|
||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<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-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem"></span>
|
||||||
|
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm: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">
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 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">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>Thinking...</p>
|
<p>{{ $strings.MessageThinking }}</p>
|
||||||
</li>
|
</li>
|
||||||
<li v-else-if="isFetching" class="py-2 px-2">
|
<li v-else-if="isFetching" class="py-2 px-2">
|
||||||
<p>Fetching...</p>
|
<p>{{ $strings.MessageFetching }}</p>
|
||||||
</li>
|
</li>
|
||||||
<li v-else-if="!totalResults" class="py-2 px-2">
|
<li v-else-if="!totalResults" class="py-2 px-2">
|
||||||
<p>No Results</p>
|
<p>{{ $strings.MessageNoResults }}</p>
|
||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelBooks }}</p>
|
||||||
<template v-for="item in bookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
|
||||||
<template v-for="item in podcastResults">
|
<template v-for="item in podcastResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
||||||
<template v-for="item in authorResults">
|
<template v-for="item in authorResults">
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
<nuxt-link :to="`/author/${item.id}`">
|
||||||
<cards-author-search-card :author="item" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelSeries }}</p>
|
||||||
<template v-for="item in seriesResults">
|
<template v-for="item in seriesResults">
|
||||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
@@ -55,11 +57,29 @@
|
|||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
||||||
<template v-for="item in tagResults">
|
<template v-for="item in tagResults">
|
||||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="`tag.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.name" />
|
<cards-tag-search-card :tag="item.name" :num-items="item.numItems" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="genreResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelGenres }}</p>
|
||||||
|
<template v-for="item in genreResults">
|
||||||
|
<li :key="`genre.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(item.name)}`">
|
||||||
|
<cards-genre-search-card :genre="item.name" :num-items="item.numItems" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
||||||
|
<template v-for="narrator in narratorResults">
|
||||||
|
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||||
|
<cards-narrator-search-card :narrator="narrator.name" :num-books="narrator.numBooks" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -84,6 +104,8 @@ export default {
|
|||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
|
genreResults: [],
|
||||||
|
narratorResults: [],
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -93,7 +115,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -104,7 +126,7 @@ export default {
|
|||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
this.clearResults()
|
this.clearResults()
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`)
|
this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)
|
||||||
},
|
},
|
||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
@@ -114,6 +136,8 @@ export default {
|
|||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
|
this.genreResults = []
|
||||||
|
this.narratorResults = []
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
@@ -142,7 +166,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
|
|
||||||
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${encodeURIComponent(value)}&limit=3`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -155,6 +179,8 @@ export default {
|
|||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
|
this.genreResults = searchResults.genres || []
|
||||||
|
this.narratorResults = searchResults.narrators || []
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
@@ -185,8 +211,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.globalSearchMenu {
|
.globalSearchMenu {
|
||||||
max-height: 80vh;
|
max-height: calc(100vh - 75px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center justify-between">
|
||||||
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
|
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||||
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in selectItems">
|
||||||
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
|
<span class="material-symbols text-2xl">arrow_right</span>
|
||||||
|
</div>
|
||||||
|
<!-- selected checkmark icon -->
|
||||||
|
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||||
|
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||||
|
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||||
|
<span class="material-symbols text-2xl">arrow_left</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<template v-for="item in sublistItems">
|
||||||
|
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- selected checkmark icon -->
|
||||||
|
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||||
|
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
isSeries: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
sublist: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
showMenu(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.sublist = this.selectedItemSublist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
libraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.libraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.libraryMediaType === 'music'
|
||||||
|
},
|
||||||
|
seriesItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthor,
|
||||||
|
textPlural: this.$strings.LabelAuthors,
|
||||||
|
value: 'authors',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNarrator,
|
||||||
|
textPlural: this.$strings.LabelNarrators,
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelPublisher,
|
||||||
|
textPlural: this.$strings.LabelPublishers,
|
||||||
|
value: 'publishers',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSeriesProgress,
|
||||||
|
value: 'progress',
|
||||||
|
sublist: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bookItems() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSeries,
|
||||||
|
textPlural: this.$strings.LabelSeries,
|
||||||
|
value: 'series',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthor,
|
||||||
|
textPlural: this.$strings.LabelAuthors,
|
||||||
|
value: 'authors',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNarrator,
|
||||||
|
textPlural: this.$strings.LabelNarrators,
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelPublisher,
|
||||||
|
textPlural: this.$strings.LabelPublishers,
|
||||||
|
value: 'publishers',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelProgress,
|
||||||
|
value: 'progress',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelMissing,
|
||||||
|
value: 'missing',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTracks,
|
||||||
|
value: 'tracks',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelEbooks,
|
||||||
|
value: 'ebooks',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAbridged,
|
||||||
|
value: 'abridged',
|
||||||
|
sublist: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
|
value: 'feed-open',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (this.userIsAdminOrUp) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelShareOpen,
|
||||||
|
value: 'share-open',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
podcastItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
if (this.isSeries) return this.seriesItems
|
||||||
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
if (this.isMusic) return this.musicItems
|
||||||
|
return this.bookItems
|
||||||
|
},
|
||||||
|
selectedItemSublist() {
|
||||||
|
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
||||||
|
},
|
||||||
|
selectedSublistText() {
|
||||||
|
if (!this.sublist) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const sublistItem = this.selectItems.find((i) => i.value === this.sublist)
|
||||||
|
return sublistItem?.textPlural || sublistItem?.text || ''
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
if (!this.selected) return ''
|
||||||
|
const parts = this.selected.split('.')
|
||||||
|
const filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||||
|
let filterValue = null
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const decoded = this.$decode(parts[1])
|
||||||
|
if (parts[0] === 'authors') {
|
||||||
|
const author = this.authors.find((au) => au.id == decoded)
|
||||||
|
if (author) filterValue = author.name
|
||||||
|
} else if (parts[0] === 'series') {
|
||||||
|
if (decoded === 'no-series') {
|
||||||
|
filterValue = this.$strings.MessageNoSeries
|
||||||
|
} else {
|
||||||
|
const series = this.series.find((se) => se.id == decoded)
|
||||||
|
if (series) filterValue = series.name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filterValue = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterName && filterValue) {
|
||||||
|
return `${filterName.text}: ${filterValue}`
|
||||||
|
} else if (filterName) {
|
||||||
|
return filterName.text
|
||||||
|
} else if (filterValue) {
|
||||||
|
return filterValue
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.filterData.genres || []
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.filterData.tags || []
|
||||||
|
},
|
||||||
|
series() {
|
||||||
|
return this.filterData.series || []
|
||||||
|
},
|
||||||
|
authors() {
|
||||||
|
return this.filterData.authors || []
|
||||||
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.filterData.narrators || []
|
||||||
|
},
|
||||||
|
languages() {
|
||||||
|
return this.filterData.languages || []
|
||||||
|
},
|
||||||
|
publishers() {
|
||||||
|
return this.filterData.publishers || []
|
||||||
|
},
|
||||||
|
progress() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'finished',
|
||||||
|
name: this.$strings.LabelFinished
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-progress',
|
||||||
|
name: this.$strings.LabelInProgress
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not-started',
|
||||||
|
name: this.$strings.LabelNotStarted
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not-finished',
|
||||||
|
name: this.$strings.LabelNotFinished
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'none',
|
||||||
|
name: this.$strings.LabelTracksNone
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'single',
|
||||||
|
name: this.$strings.LabelTracksSingleTrack
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'multi',
|
||||||
|
name: this.$strings.LabelTracksMultiTrack
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ebooks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ebook',
|
||||||
|
name: this.$strings.LabelHasEbook
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'no-ebook',
|
||||||
|
name: this.$strings.LabelMissingEbook
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'supplementary',
|
||||||
|
name: this.$strings.LabelHasSupplementaryEbook
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'no-supplementary',
|
||||||
|
name: this.$strings.LabelMissingSupplementaryEbook
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
missing() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'asin',
|
||||||
|
name: 'ASIN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'isbn',
|
||||||
|
name: 'ISBN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle',
|
||||||
|
name: this.$strings.LabelSubtitle
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'authors',
|
||||||
|
name: this.$strings.LabelAuthor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'publishedYear',
|
||||||
|
name: this.$strings.LabelPublishYear
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'series',
|
||||||
|
name: this.$strings.LabelSeries
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'description',
|
||||||
|
name: this.$strings.LabelDescription
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'genres',
|
||||||
|
name: this.$strings.LabelGenres
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tags',
|
||||||
|
name: this.$strings.LabelTags
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'narrators',
|
||||||
|
name: this.$strings.LabelNarrator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'publisher',
|
||||||
|
name: this.$strings.LabelPublisher
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'language',
|
||||||
|
name: this.$strings.LabelLanguage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cover',
|
||||||
|
name: this.$strings.LabelCover
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sublistItems() {
|
||||||
|
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return {
|
||||||
|
text: item,
|
||||||
|
value: this.$encode(item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
text: item.name,
|
||||||
|
value: this.$encode(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (this.sublist === 'series') {
|
||||||
|
sublistItems.unshift({
|
||||||
|
text: this.$strings.MessageNoSeries,
|
||||||
|
value: this.$encode('no-series')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sublistItems
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clearSelected() {
|
||||||
|
this.selected = 'all'
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', 'all'))
|
||||||
|
},
|
||||||
|
clickOutside() {
|
||||||
|
if (!this.selectedItemSublist) this.sublist = null
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedSublistOption(item) {
|
||||||
|
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||||
|
},
|
||||||
|
clickedOption(option) {
|
||||||
|
if (option.sublist) {
|
||||||
|
this.sublist = option.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = option.value
|
||||||
|
if (this.selected === val) {
|
||||||
|
this.showMenu = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.selected = val
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.libraryFilterMenu {
|
||||||
|
max-height: calc(100vh - 125px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center justify-between">
|
||||||
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
|
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in selectItems">
|
||||||
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
|
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
descending: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedDesc: {
|
||||||
|
get() {
|
||||||
|
return this.descending
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:descending', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.libraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.libraryMediaType === 'music'
|
||||||
|
},
|
||||||
|
podcastItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTitle,
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthor,
|
||||||
|
value: 'media.metadata.author'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSize,
|
||||||
|
value: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNumberOfEpisodes,
|
||||||
|
value: 'media.numTracks'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileBirthtime,
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileModified,
|
||||||
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bookItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTitle,
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthorFirstLast,
|
||||||
|
value: 'media.metadata.authorName'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthorLastFirst,
|
||||||
|
value: 'media.metadata.authorNameLF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelPublishYear,
|
||||||
|
value: 'media.metadata.publishedYear'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSize,
|
||||||
|
value: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelDuration,
|
||||||
|
value: 'media.duration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileBirthtime,
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileModified,
|
||||||
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
seriesItems() {
|
||||||
|
return [
|
||||||
|
...this.bookItems,
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSequence,
|
||||||
|
value: 'sequence'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTitle,
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSize,
|
||||||
|
value: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelDuration,
|
||||||
|
value: 'media.duration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileBirthtime,
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileModified,
|
||||||
|
value: 'mtimeMs'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
let items = null
|
||||||
|
if (this.isPodcast) {
|
||||||
|
items = this.podcastItems
|
||||||
|
} else if (this.isMusic) {
|
||||||
|
items = this.musicItems
|
||||||
|
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
||||||
|
items = this.seriesItems
|
||||||
|
} else {
|
||||||
|
items = this.bookItems
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.some((i) => i.value === this.selected)) {
|
||||||
|
this.selected = items[0].value
|
||||||
|
this.selectedDesc = !this.defaultsToAsc(items[0].value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
var _selected = this.selected
|
||||||
|
if (!_selected) return ''
|
||||||
|
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
||||||
|
var _sel = this.selectItems.find((i) => i.value === _selected)
|
||||||
|
if (!_sel) return ''
|
||||||
|
return _sel.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(val) {
|
||||||
|
if (this.selected === val) {
|
||||||
|
this.selectedDesc = !this.selectedDesc
|
||||||
|
} else {
|
||||||
|
this.selected = val
|
||||||
|
if (this.defaultsToAsc(val)) this.selectedDesc = false
|
||||||
|
}
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
},
|
||||||
|
defaultsToAsc(val) {
|
||||||
|
return val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF' || val == 'sequence'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
|
||||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
|
||||||
<span class="flex items-center justify-between">
|
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
|
||||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 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" role="listbox" aria-labelledby="listbox-label">
|
|
||||||
<template v-for="item in selectItems">
|
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
|
||||||
</div>
|
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
|
||||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: String,
|
|
||||||
descending: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showMenu: false,
|
|
||||||
bookItems: [
|
|
||||||
{
|
|
||||||
text: 'Title',
|
|
||||||
value: 'media.metadata.title'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Author (First Last)',
|
|
||||||
value: 'media.metadata.authorName'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Author (Last, First)',
|
|
||||||
value: 'media.metadata.authorNameLF'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Published Year',
|
|
||||||
value: 'media.metadata.publishedYear'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Added At',
|
|
||||||
value: 'addedAt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Size',
|
|
||||||
value: 'size'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Duration',
|
|
||||||
value: 'media.duration'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'File Birthtime',
|
|
||||||
value: 'birthtimeMs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'File Modified',
|
|
||||||
value: 'mtimeMs'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
podcastItems: [
|
|
||||||
{
|
|
||||||
text: 'Title',
|
|
||||||
value: 'media.metadata.title'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Author',
|
|
||||||
value: 'media.metadata.author'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Added At',
|
|
||||||
value: 'addedAt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Size',
|
|
||||||
value: 'size'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '# of Episodes',
|
|
||||||
value: 'media.numTracks'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'File Birthtime',
|
|
||||||
value: 'birthtimeMs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'File Modified',
|
|
||||||
value: 'mtimeMs'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedDesc: {
|
|
||||||
get() {
|
|
||||||
return this.descending
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:descending', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isPodcast() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
|
||||||
},
|
|
||||||
selectItems() {
|
|
||||||
if (this.isPodcast) return this.podcastItems
|
|
||||||
return this.bookItems
|
|
||||||
},
|
|
||||||
selectedText() {
|
|
||||||
var _selected = this.selected
|
|
||||||
if (!_selected) return ''
|
|
||||||
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
|
||||||
var _sel = this.selectItems.find((i) => i.value === _selected)
|
|
||||||
if (!_sel) return ''
|
|
||||||
return _sel.text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
clickOutside() {
|
|
||||||
this.showMenu = false
|
|
||||||
},
|
|
||||||
clickedOption(val) {
|
|
||||||
if (this.selected === val) {
|
|
||||||
this.selectedDesc = !this.selectedDesc
|
|
||||||
} else {
|
|
||||||
this.selected = val
|
|
||||||
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
|
|
||||||
this.selectedDesc = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.showMenu = false
|
|
||||||
this.$nextTick(() => this.$emit('change', val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<template v-for="rate in rates">
|
<template v-for="rate in rates">
|
||||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||||
<div class="w-full h-full flex justify-center items-center">
|
<div class="w-full h-full flex justify-center items-center">
|
||||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="w-full py-1 px-4">
|
<div class="w-full py-1 px-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
currentPlaybackRate: 0,
|
currentPlaybackRate: 0,
|
||||||
MIN_SPEED: 0.5,
|
MIN_SPEED: 0.5,
|
||||||
MAX_SPEED: 3,
|
MAX_SPEED: 10,
|
||||||
menuLeft: -92,
|
menuLeft: -92,
|
||||||
arrowLeft: 0
|
arrowLeft: 0
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-26
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 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" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<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" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,29 +26,15 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: String,
|
value: String,
|
||||||
descending: Boolean
|
descending: Boolean,
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: 'Pub Date',
|
|
||||||
value: 'publishedAt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Title',
|
|
||||||
value: 'title'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Season',
|
|
||||||
value: 'season'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Episode',
|
|
||||||
value: 'episode'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</div>
|
</button>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||||
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||||
@@ -37,6 +37,11 @@ export default {
|
|||||||
return this.value
|
return this.value
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('volume', val)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to store volume', err)
|
||||||
|
}
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,10 @@ export default {
|
|||||||
if (this.value === 0) {
|
if (this.value === 0) {
|
||||||
this.isMute = true
|
this.isMute = true
|
||||||
}
|
}
|
||||||
|
const storageVolume = localStorage.getItem('volume')
|
||||||
|
if (storageVolume) {
|
||||||
|
this.volume = parseFloat(storageVolume)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('mousewheel', this.scroll)
|
window.removeEventListener('mousewheel', this.scroll)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default {
|
|||||||
if (!this.imagePath) return null
|
if (!this.imagePath) return null
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
// Testing
|
// Testing
|
||||||
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
return `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
}
|
}
|
||||||
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
}
|
}
|
||||||
@@ -84,4 +84,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" />
|
||||||
|
|
||||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</div>
|
</div>
|
||||||
@@ -17,17 +18,17 @@
|
|||||||
<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 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">
|
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||||
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,6 +44,7 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
|
expandOnClick: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -94,13 +96,19 @@ export default {
|
|||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.libraryItem) return null
|
if (!this.libraryItem) return null
|
||||||
var store = this.$store || this.$nuxt.$store
|
const store = this.$store || this.$nuxt.$store
|
||||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
|
rawCoverUrl() {
|
||||||
|
if (!this.libraryItem) return null
|
||||||
|
const store = this.$store || this.$nuxt.$store
|
||||||
|
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, true)
|
||||||
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.media.coverPath || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
},
|
},
|
||||||
@@ -123,14 +131,16 @@ export default {
|
|||||||
authorBottom() {
|
authorBottom() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
resolution() {
|
resolution() {
|
||||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickCover() {
|
||||||
|
if (this.expandOnClick && this.libraryItem) {
|
||||||
|
this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
setCoverBg() {
|
setCoverBg() {
|
||||||
if (this.$refs.coverBg) {
|
if (this.$refs.coverBg) {
|
||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export default {
|
|||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
groupTo: String,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -139,7 +138,7 @@ export default {
|
|||||||
|
|
||||||
var innerP = document.createElement('p')
|
var innerP = document.createElement('p')
|
||||||
innerP.textContent = this.name
|
innerP.textContent = this.name
|
||||||
innerP.className = 'text-sm font-book text-white'
|
innerP.className = 'text-sm text-white'
|
||||||
imgdiv.appendChild(innerP)
|
imgdiv.appendChild(innerP)
|
||||||
|
|
||||||
return imgdiv
|
return imgdiv
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
|
||||||
<covers-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['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
|
||||||
},
|
|
||||||
hasCover() {
|
|
||||||
return !!this.audiobook.book.cover
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
mouseover() {
|
|
||||||
this.isHovering = true
|
|
||||||
},
|
|
||||||
mouseleave() {
|
|
||||||
this.isHovering = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||||
|
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
width: Number,
|
||||||
|
height: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.width / (120 * 1.6 * 2)
|
||||||
|
},
|
||||||
|
itemCoverWidth() {
|
||||||
|
if (this.libraryItemCovers.length === 1) return this.width
|
||||||
|
return this.width / 2
|
||||||
|
},
|
||||||
|
libraryItemCovers() {
|
||||||
|
if (!this.items.length) return []
|
||||||
|
if (this.items.length === 1) return [this.items[0].libraryItem]
|
||||||
|
|
||||||
|
const covers = []
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let index = i % this.items.length
|
||||||
|
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
|
||||||
|
|
||||||
|
covers.push(this.items[index].libraryItem)
|
||||||
|
}
|
||||||
|
return covers
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,21 +4,21 @@
|
|||||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</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="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
|
|
||||||
<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' }">
|
<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>
|
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 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">
|
<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' }" />
|
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,7 +31,11 @@ export default {
|
|||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
showOpenNewTab: Boolean,
|
showOpenNewTab: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
showResolution: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -54,11 +58,18 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.width / 120
|
return this.width / 120
|
||||||
},
|
},
|
||||||
|
invalidCoverFontSize() {
|
||||||
|
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
||||||
|
},
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
resolution() {
|
resolution() {
|
||||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||||
|
},
|
||||||
|
placeholderUrl() {
|
||||||
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -68,7 +79,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
if (this.$refs.cover) {
|
if (this.$refs.cover && this.src !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
this.naturalHeight = naturalHeight
|
this.naturalHeight = naturalHeight
|
||||||
this.naturalWidth = naturalWidth
|
this.naturalWidth = naturalWidth
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
|
|
||||||
<path
|
|
||||||
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
|
|
||||||
transform="translate(-557.82 -281.22)"
|
|
||||||
/>
|
|
||||||
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
|
|
||||||
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
|
|
||||||
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
|
|
||||||
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg class="p-px" 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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg class="p-px" viewBox="0 0 122.877 120.596">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -2,108 +2,119 @@
|
|||||||
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="newUser.username" label="Username" />
|
<ui-text-input-with-label v-model.trim="newUser.username" :label="$strings.LabelUsername" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
|
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||||
|
<ui-text-input-with-label v-else v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!isEditingRoot" class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="px-2 w-52">
|
<div class="w-1/2 px-2">
|
||||||
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="px-2 w-52">
|
||||||
|
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-4 px-2">
|
<div class="flex items-center pt-4 px-2">
|
||||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
<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>
|
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Download</p>
|
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.download" />
|
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Update</p>
|
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.update" />
|
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Delete</p>
|
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Upload</p>
|
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Access Explicit Content</p>
|
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
|
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Access All Libraries</p>
|
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-cen~ter my-2 max-w-md">
|
<div class="flex items-cen~ter my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Access All Tags</p>
|
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
<div class="flex items-center">
|
||||||
|
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||||
|
<div class="flex items-center pt-4 px-2">
|
||||||
|
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||||
|
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4 px-2">
|
<div class="flex pt-4 px-2">
|
||||||
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
|
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">{{ $strings.ButtonUnlinkOpenId }}</ui-btn>
|
||||||
|
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,22 +136,9 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
accountTypes: [
|
|
||||||
{
|
|
||||||
text: 'Guest',
|
|
||||||
value: 'guest'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'User',
|
|
||||||
value: 'user'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Admin',
|
|
||||||
value: 'admin'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tags: [],
|
tags: [],
|
||||||
loadingTags: false
|
loadingTags: false,
|
||||||
|
unlinkingFromOpenID: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -161,14 +159,30 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
accountTypes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAccountTypeGuest,
|
||||||
|
value: 'guest'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAccountTypeUser,
|
||||||
|
value: 'user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAccountTypeAdmin,
|
||||||
|
value: 'admin'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account?.type === 'root'
|
||||||
},
|
},
|
||||||
libraries() {
|
libraries() {
|
||||||
return this.$store.state.libraries.libraries
|
return this.$store.state.libraries.libraries
|
||||||
@@ -183,6 +197,12 @@ export default {
|
|||||||
value: t
|
value: t
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
tagsSelectionText() {
|
||||||
|
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||||
|
},
|
||||||
|
hasOpenIDLink() {
|
||||||
|
return !!this.account?.hasOpenIDLink
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -190,17 +210,45 @@ export default {
|
|||||||
// Force close when navigating - used in UsersTable
|
// Force close when navigating - used in UsersTable
|
||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
|
unlinkOpenID() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmUnlinkOpenId,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.unlinkingFromOpenID = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to unlink user from OpenID', error)
|
||||||
|
this.$toast.error(this.$strings.ToastUnlinkOpenIdFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.unlinkingFromOpenID = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (val && this.newUser.itemTagsAccessible.length) {
|
if (val) {
|
||||||
this.newUser.itemTagsAccessible = []
|
if (this.newUser.itemTagsSelected?.length) {
|
||||||
|
this.newUser.itemTagsSelected = []
|
||||||
|
}
|
||||||
|
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchAllTags() {
|
fetchAllTags() {
|
||||||
this.loadingTags = true
|
this.loadingTags = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/tags`)
|
.$get(`/api/tags`)
|
||||||
.then((tags) => {
|
.then((res) => {
|
||||||
this.tags = tags
|
this.tags = res.tags
|
||||||
this.loadingTags = false
|
this.loadingTags = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -217,15 +265,15 @@ export default {
|
|||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.newUser.username) {
|
if (!this.newUser.username) {
|
||||||
this.$toast.error('Enter a username')
|
this.$toast.error(this.$strings.ToastNewUserUsernameError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error(this.$strings.ToastNewUserLibraryError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
||||||
this.$toast.error('Must select at least one tag')
|
this.$toast.error(this.$strings.ToastNewUserTagError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,13 +291,12 @@ export default {
|
|||||||
if (account.type === 'root' && !account.isActive) return
|
if (account.type === 'root' && !account.isActive) return
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
console.log('Calling update', account)
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/users/${this.account.id}`, account)
|
.$patch(`/api/users/${this.account.id}`, account)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
console.log('Account updated', data.user)
|
||||||
|
|
||||||
@@ -258,7 +305,7 @@ export default {
|
|||||||
this.$store.commit('user/setUserToken', data.user.token)
|
this.$store.commit('user/setUserToken', data.user.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$toast.success('Account updated')
|
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -266,12 +313,12 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
console.error('Failed to update account', error)
|
console.error('Failed to update account', error)
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || 'Failed to update account')
|
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdateAccount)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitCreateAccount() {
|
submitCreateAccount() {
|
||||||
if (!this.newUser.password) {
|
if (!this.newUser.password) {
|
||||||
this.$toast.error('Must have a password, only root user can have an empty password')
|
this.$toast.error(this.$strings.ToastNewUserPasswordError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,9 +329,9 @@ export default {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(`Failed to create account: ${data.error}`)
|
this.$toast.error(this.$strings.ToastNewUserCreatedFailed + ': ' + data.error)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success('New account created')
|
this.$toast.success(this.$strings.ToastNewUserCreatedSuccess)
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -304,27 +351,31 @@ export default {
|
|||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin',
|
upload: type === 'admin',
|
||||||
|
accessExplicitContent: true,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true
|
accessAllTags: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchAllTags()
|
this.fetchAllTags()
|
||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
|
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
|
email: this.account.email,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: null,
|
username: null,
|
||||||
|
email: null,
|
||||||
password: null,
|
password: null,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -334,9 +385,12 @@ export default {
|
|||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true
|
accessAllTags: true,
|
||||||
|
accessExplicitContent: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: [],
|
||||||
|
itemTagsSelected: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ $strings.HeaderAddCustomMetadataProvider }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<div class="w-full p-8">
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-3/4 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 p-1">
|
||||||
|
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full mb-2 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full mb-2 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
|
||||||
|
</div>
|
||||||
|
<div class="flex px-1 pt-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newName: '',
|
||||||
|
newUrl: '',
|
||||||
|
newAuthHeaderValue: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitForm() {
|
||||||
|
if (!this.newName || !this.newUrl) {
|
||||||
|
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/custom-metadata-providers', {
|
||||||
|
name: this.newName,
|
||||||
|
url: this.newUrl,
|
||||||
|
mediaType: 'book', // Currently only supporting book mediaType
|
||||||
|
authHeaderValue: this.newAuthHeaderValue
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
this.$emit('added', data.provider)
|
||||||
|
this.$toast.success(this.$strings.ToastProviderCreatedSuccess)
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Unknown error'
|
||||||
|
console.error('Failed to add provider', error)
|
||||||
|
this.$toast.error(this.$strings.ToastProviderCreatedFailed + ': ' + errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.processing = false
|
||||||
|
this.newName = ''
|
||||||
|
this.newUrl = ''
|
||||||
|
this.newAuthHeaderValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||||
|
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
||||||
|
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
|
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">{{ $strings.ButtonProbeAudioFile }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<template v-if="!ffprobeData">
|
||||||
|
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row text-sm">
|
||||||
|
<div class="w-full sm:w-1/2">
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelSize }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelDuration }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||||
|
<p>{{ audioFile.format }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelChapters }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelEmbeddedCover }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-1/2">
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelCodec }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.codec }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelChannels }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelBitrate }}
|
||||||
|
</p>
|
||||||
|
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||||
|
<p>{{ audioFile.timeBase }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="audioFile.language" class="flex mb-1">
|
||||||
|
<p class="w-32 text-black-50">
|
||||||
|
{{ $strings.LabelLanguage }}
|
||||||
|
</p>
|
||||||
|
<p>{{ audioFile.language || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||||
|
|
||||||
|
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||||
|
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||||
|
{{ key.replace('tag', '') }}
|
||||||
|
</p>
|
||||||
|
<p>{{ value }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<div class="relative">
|
||||||
|
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
||||||
|
|
||||||
|
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||||
|
<span class="material-symbols">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
audioFile: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryItemId: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
probingFile: false,
|
||||||
|
ffprobeData: null,
|
||||||
|
copiedToClipboard: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.ffprobeData = null
|
||||||
|
this.copiedToClipboard = false
|
||||||
|
this.probingFile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata() {
|
||||||
|
return this.audioFile?.metadata || {}
|
||||||
|
},
|
||||||
|
metaTags() {
|
||||||
|
return this.audioFile?.metaTags || {}
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
prettyFfprobeData() {
|
||||||
|
if (!this.ffprobeData) return ''
|
||||||
|
return JSON.stringify(this.ffprobeData, null, 2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getFFProbeData() {
|
||||||
|
this.probingFile = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
||||||
|
.then((data) => {
|
||||||
|
console.log('Got ffprobe data', data)
|
||||||
|
this.ffprobeData = data
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to get ffprobe data', error)
|
||||||
|
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.probingFile = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async copyFfprobeData() {
|
||||||
|
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">Set Backup Schedule</p>
|
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div v-if="show && newCronExpression" 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">
|
||||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? 'Save Backup Schedule' : 'No update necessary' }}</ui-btn>
|
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
|
<div v-if="show" class="w-full h-full py-4">
|
||||||
|
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||||
|
<div class="flex px-8 items-center py-2">
|
||||||
|
<p class="pr-4">{{ $strings.LabelProvider }}</p>
|
||||||
|
<ui-dropdown v-model="options.provider" :items="providers" small />
|
||||||
|
</div>
|
||||||
|
<p class="text-base px-8 py-2">{{ $strings.MessageBatchQuickMatchDescription }}</p>
|
||||||
|
<div class="flex px-8 items-end py-2">
|
||||||
|
<ui-toggle-switch v-model="options.overrideCover" />
|
||||||
|
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
|
||||||
|
<p class="pl-4">
|
||||||
|
{{ $strings.LabelUpdateCover }}
|
||||||
|
<span class="material-symbols icon-text">info</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex px-8 items-end py-2">
|
||||||
|
<ui-toggle-switch v-model="options.overrideDetails" />
|
||||||
|
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
|
||||||
|
<p class="pl-4">
|
||||||
|
{{ $strings.LabelUpdateDetails }}
|
||||||
|
<span class="material-symbols icon-text">info</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 text-white text-opacity-80 border-t border-white border-opacity-5">
|
||||||
|
<div class="flex items-center px-4">
|
||||||
|
<ui-btn type="button" @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" @click="doBatchQuickMatch">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
lastUsedLibrary: undefined,
|
||||||
|
options: {
|
||||||
|
provider: undefined,
|
||||||
|
overrideDetails: true,
|
||||||
|
overrideCover: true,
|
||||||
|
overrideDefaults: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showBatchQuickMatchModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowBatchQuickMatchModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])
|
||||||
|
},
|
||||||
|
showBatchQuickMatchModal() {
|
||||||
|
return this.$store.state.globals.showBatchQuickMatchModal
|
||||||
|
},
|
||||||
|
selectedBookIds() {
|
||||||
|
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||||
|
// the selected provider to the current library default provider
|
||||||
|
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
|
||||||
|
this.options.lastUsedLibrary = this.currentLibraryId
|
||||||
|
this.options.provider = this.libraryProvider
|
||||||
|
}
|
||||||
|
},
|
||||||
|
doBatchQuickMatch() {
|
||||||
|
if (!this.selectedBookIds.length) return
|
||||||
|
if (this.processing) return
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/batch/quickmatch`, {
|
||||||
|
options: this.options,
|
||||||
|
libraryItemIds: this.selectedBookIds
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error('Batch quick match failed')
|
||||||
|
console.error('Failed to batch quick match', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">Your Bookmarks</p>
|
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||||
<p class="text-xl">No Bookmarks</p>
|
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">add</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +73,12 @@ export default {
|
|||||||
},
|
},
|
||||||
canCreateBookmark() {
|
canCreateBookmark() {
|
||||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||||
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.state.serverSettings.timeFormat
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -85,10 +91,10 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark removed')
|
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to remove bookmark`)
|
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
@@ -101,17 +107,17 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark updated')
|
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to update bookmark`)
|
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||||
}
|
}
|
||||||
var bookmark = {
|
var bookmark = {
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
@@ -120,10 +126,10 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark added')
|
this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to create bookmark`)
|
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -134,4 +140,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<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-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
||||||
<p class="chapter-title truncate text-sm md:text-base">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</p>
|
||||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||||
<span class="flex-grow" />
|
<span class="flex-grow" />
|
||||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||||
|
|
||||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -28,16 +28,12 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
value(newVal) {
|
|
||||||
this.$nextTick(this.scrollToChapter)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
@@ -47,11 +43,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
_playbackRate() {
|
||||||
|
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter?.id || null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return this.currentChapter ? this.currentChapter.start : 0
|
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -61,16 +61,19 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
var container = this.$refs.container
|
if (this.$refs.container) {
|
||||||
if (container) {
|
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
var offsetTop = currChapterEl.offsetTop
|
const containerHeight = this.$refs.container.clientHeight
|
||||||
var containerHeight = container.clientHeight
|
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
if (this.value) {
|
||||||
|
this.$nextTick(this.scrollToChapter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -84,4 +87,4 @@ export default {
|
|||||||
max-width: calc(100% - 150px);
|
max-width: calc(100% - 150px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', action)
|
this.$emit('action', { action })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
|
||||||
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||||
<span class="material-icons text-2xl md:text-4xl">close</span>
|
<span class="material-symbols text-2xl md:text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref="content" class="text-white">
|
<div ref="content" class="text-white">
|
||||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" label="Series Name" />
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" @input="seriesNameInputHandler" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" label="Sequence" />
|
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-2 p-1">
|
<div class="flex justify-end mt-2 p-1">
|
||||||
<ui-btn type="submit">Save</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -66,6 +66,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
seriesNameInputHandler() {
|
||||||
|
if (this.$refs.sequenceInput) {
|
||||||
|
this.$refs.sequenceInput.setFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
setInputFocus() {
|
setInputFocus() {
|
||||||
if (this.isNewSeries) {
|
if (this.isNewSeries) {
|
||||||
// Focus on series input if new series
|
// Focus on series input if new series
|
||||||
@@ -134,4 +139,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,103 +2,105 @@
|
|||||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
<div class="flex flex-wrap mb-4">
|
<div class="flex flex-wrap mb-4">
|
||||||
<div class="w-full md:w-2/3">
|
<div class="w-full md:w-2/3">
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">{{ $strings.HeaderDetails }}</p>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Started At</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Updated At</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Listened for</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelTimeListened }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Start Time</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartTime }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $secondsToTimestamp(_session.startTime) }}
|
{{ $secondsToTimestamp(_session.startTime) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Last Time</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLastTime }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $secondsToTimestamp(_session.currentTime) }}
|
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Library Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.libraryId }}
|
{{ _session.libraryId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Library Item Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.libraryItemId }}
|
{{ _session.libraryItemId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Episode Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||||
<div class="px-1">
|
<div class="px-1 text-xs">
|
||||||
{{ _session.episodeId }}
|
{{ _session.episodeId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Media Type</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelMediaType }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ _session.mediaType }}
|
{{ _session.mediaType }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">Duration</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelDuration }}</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
{{ $elapsedPretty(_session.duration) }}
|
{{ $elapsedPretty(_session.duration) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/3">
|
<div class="w-full md:w-1/3">
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">User</p>
|
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||||
<p class="mb-1">{{ _session.userId }}</p>
|
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||||
|
|
||||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
|
||||||
|
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
<p v-if="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</p>
|
||||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
|
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||||
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
|
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
|
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">{{ $strings.ButtonCloseSession }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -140,10 +142,14 @@ export default {
|
|||||||
if (!this.deviceInfo.osName) return null
|
if (!this.deviceInfo.osName) return null
|
||||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
},
|
},
|
||||||
clientDisplayName() {
|
deviceDisplayName() {
|
||||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||||
},
|
},
|
||||||
|
clientDisplayName() {
|
||||||
|
if (!this.deviceInfo.clientName) return null
|
||||||
|
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
|
||||||
|
},
|
||||||
playMethodName() {
|
playMethodName() {
|
||||||
const playMethod = this._session.playMethod
|
const playMethod = this._session.playMethod
|
||||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
@@ -151,12 +157,24 @@ export default {
|
|||||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
},
|
||||||
|
isOpenSession() {
|
||||||
|
return !!this._session.open
|
||||||
|
},
|
||||||
|
isMediaItemShareSession() {
|
||||||
|
return this._session.mediaPlayer === 'web-share'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
deleteSessionClick() {
|
deleteSessionClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: `Are you sure you want to delete this session?`,
|
message: this.$strings.MessageConfirmDeleteSession,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.deleteSession()
|
this.deleteSession()
|
||||||
@@ -172,7 +190,7 @@ export default {
|
|||||||
.$delete(`/api/sessions/${this._session.id}`)
|
.$delete(`/api/sessions/${this._session.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.success('Session deleted successfully')
|
this.$toast.success(this.$strings.ToastSessionDeleteSuccess)
|
||||||
this.$emit('removedSession')
|
this.$emit('removedSession')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
@@ -180,10 +198,27 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
console.error('Failed to delete session', error)
|
console.error('Failed to delete session', error)
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || 'Failed to delete session')
|
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeSessionClick() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/session/${this._session.id}/close`)
|
||||||
|
.then(() => {
|
||||||
|
this.show = false
|
||||||
|
this.$emit('closedSession')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close session', error)
|
||||||
|
const errMsg = error.response?.data || ''
|
||||||
|
this.$toast.error(errMsg || this.$strings.ToastSessionCloseFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<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-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-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</div>
|
</button>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<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">
|
<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 />
|
<ui-loading-indicator />
|
||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
},
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 50
|
default: 60
|
||||||
},
|
},
|
||||||
bgOpacity: {
|
bgOpacity: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="player-settings" :width="500" :height="'unset'">
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4" style="max-height: 80vh; min-height: 40vh">
|
||||||
|
<h3 class="text-xl font-semibold mb-8">{{ $strings.HeaderPlayerSettings }}</h3>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<ui-toggle-switch v-model="useChapterTrack" @input="setUseChapterTrack" />
|
||||||
|
<div class="pl-4">
|
||||||
|
<span>{{ $strings.LabelUseChapterTrack }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
useChapterTrack: false,
|
||||||
|
jumpValues: [
|
||||||
|
{ text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },
|
||||||
|
{ text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },
|
||||||
|
{ text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },
|
||||||
|
{ text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },
|
||||||
|
{ text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },
|
||||||
|
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
||||||
|
],
|
||||||
|
jumpForwardAmount: 10,
|
||||||
|
jumpBackwardAmount: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setUseChapterTrack() {
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
|
||||||
|
},
|
||||||
|
setJumpForwardAmount(val) {
|
||||||
|
this.jumpForwardAmount = val
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })
|
||||||
|
},
|
||||||
|
setJumpBackwardAmount(val) {
|
||||||
|
this.jumpBackwardAmount = val
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||||
|
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||||
|
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0">
|
||||||
|
<div class="w-full h-full" @click="show = false">
|
||||||
|
<img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" />
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showRawCoverPreviewModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rawCoverUrl() {
|
||||||
|
return this.$store.state.globals.selectedRawCoverUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="share" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ $strings.LabelShare }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
|
<div class="absolute top-0 right-0 p-4">
|
||||||
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<template v-if="currentShare">
|
||||||
|
<div class="w-full py-2">
|
||||||
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
||||||
|
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full py-2 px-1">
|
||||||
|
<p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||||
|
<p v-else>{{ $strings.LabelPermanent }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4">
|
||||||
|
<div class="w-full sm:w-48">
|
||||||
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
||||||
|
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="w-full sm:w-80">
|
||||||
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelDuration }}</label>
|
||||||
|
<div class="inline-flex items-center space-x-2">
|
||||||
|
<div>
|
||||||
|
<ui-icon-btn icon="remove" :size="10" @click="clickMinus" />
|
||||||
|
</div>
|
||||||
|
<ui-text-input v-model="newShareDuration" type="number" text-center no-spinner class="text-center max-w-12 min-w-12 h-10 text-base" />
|
||||||
|
<div>
|
||||||
|
<ui-icon-btn icon="add" :size="10" @click="clickPlus" />
|
||||||
|
</div>
|
||||||
|
<div class="w-28">
|
||||||
|
<ui-dropdown v-model="shareDurationUnit" :items="durationUnits" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
|
||||||
|
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
||||||
|
</template>
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
|
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newShareSlug: '',
|
||||||
|
newShareDuration: 0,
|
||||||
|
currentShare: null,
|
||||||
|
shareDurationUnit: 'minutes',
|
||||||
|
durationUnits: [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelMinutes,
|
||||||
|
value: 'minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelHours,
|
||||||
|
value: 'hours'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelDays,
|
||||||
|
value: 'days'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showShareModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowShareModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaItemShare() {
|
||||||
|
return this.$store.state.globals.selectedMediaItemShare
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
|
demoShareUrl() {
|
||||||
|
return `${window.origin}/share/${this.newShareSlug}`
|
||||||
|
},
|
||||||
|
currentShareUrl() {
|
||||||
|
if (!this.currentShare) return ''
|
||||||
|
return `${window.origin}/share/${this.currentShare.slug}`
|
||||||
|
},
|
||||||
|
currentShareTimeRemaining() {
|
||||||
|
if (!this.currentShare) return 'Error'
|
||||||
|
if (!this.currentShare.expiresAt) return this.$strings.LabelPermanent
|
||||||
|
const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()
|
||||||
|
if (msRemaining <= 0) return 'Expired'
|
||||||
|
return this.$elapsedPrettyExtended(msRemaining / 1000, true, false)
|
||||||
|
},
|
||||||
|
expireDurationSeconds() {
|
||||||
|
let shareDuration = Number(this.newShareDuration)
|
||||||
|
if (!shareDuration || isNaN(shareDuration)) return 0
|
||||||
|
return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)
|
||||||
|
},
|
||||||
|
expirationDateString() {
|
||||||
|
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||||
|
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||||
|
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickPlus() {
|
||||||
|
this.newShareDuration++
|
||||||
|
},
|
||||||
|
clickMinus() {
|
||||||
|
if (this.newShareDuration > 0) {
|
||||||
|
this.newShareDuration--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteShare() {
|
||||||
|
if (!this.currentShare) return
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/share/mediaitem/${this.currentShare.id}`)
|
||||||
|
.then(() => {
|
||||||
|
this.currentShare = null
|
||||||
|
this.$emit('removed')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('deleteShare', error)
|
||||||
|
let errorMsg = error.response?.data || 'Failed to delete share'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openShare() {
|
||||||
|
if (!this.newShareSlug) {
|
||||||
|
this.$toast.error(this.$strings.ToastSlugRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
slug: this.newShareSlug,
|
||||||
|
mediaItemType: 'book',
|
||||||
|
mediaItemId: this.libraryItem.media.id,
|
||||||
|
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/share/mediaitem`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.currentShare = data
|
||||||
|
this.$emit('opened', data)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('openShare', error)
|
||||||
|
let errorMsg = error.response?.data || 'Failed to share item'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.newShareSlug = this.$randomId(10)
|
||||||
|
if (this.mediaItemShare) {
|
||||||
|
this.currentShare = { ...this.mediaItemShare }
|
||||||
|
} else {
|
||||||
|
this.currentShare = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,37 +2,43 @@
|
|||||||
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
|
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
<p class="font-book text-3xl text-white truncate pointer-events-none">Sleep Timer</p>
|
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="!timerSet" class="w-full">
|
<div class="w-full">
|
||||||
<template v-for="time in sleepTimes">
|
<template v-for="time in sleepTimes">
|
||||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative" @click="setTime(time)">
|
||||||
<p class="text-xl text-center">{{ time.text }}</p>
|
<p class="text-lg text-center">{{ time.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||||
|
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
||||||
|
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-18 flex items-center justify-center ml-1">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full p-4">
|
<div v-if="timerSet" class="w-full p-4">
|
||||||
<div class="mb-4 flex items-center justify-center">
|
<div class="mb-4 h-px w-full bg-white/10" />
|
||||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
|
||||||
<span class="material-icons text-lg">remove</span>
|
<div v-if="timerType === $constants.SleepTimerTypes.COUNTDOWN" class="mb-4 flex items-center justify-center space-x-4">
|
||||||
<span class="pl-1 text-base font-mono">30m</span>
|
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center h-9" @click="decrement(30 * 60)">
|
||||||
|
<span class="material-symbols text-lg">remove</span>
|
||||||
|
<span class="pl-1 text-sm">30m</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
<ui-icon-btn icon="remove" class="min-w-9" @click="decrement(60 * 5)" />
|
||||||
|
|
||||||
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
<p class="text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||||
|
|
||||||
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
<ui-icon-btn icon="add" class="min-w-9" @click="increment(60 * 5)" />
|
||||||
|
|
||||||
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
<ui-btn :padding-x="2" small class="flex items-center h-9" @click="increment(30 * 60)">
|
||||||
<span class="material-icons text-lg">add</span>
|
<span class="material-symbols text-lg">add</span>
|
||||||
<span class="pl-1 text-base font-mono">30m</span>
|
<span class="pl-1 text-sm">30m</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="w-full" @click="$emit('cancel')">Cancel</ui-btn>
|
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -43,47 +49,13 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
timerSet: Boolean,
|
timerSet: Boolean,
|
||||||
timerTime: Number,
|
timerType: String,
|
||||||
remaining: Number
|
remaining: Number,
|
||||||
|
hasChapters: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sleepTimes: [
|
customTime: null
|
||||||
{
|
|
||||||
seconds: 10,
|
|
||||||
text: '10 seconds'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 5,
|
|
||||||
text: '5 minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 30,
|
|
||||||
text: '30 minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 60,
|
|
||||||
text: '60 minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 90,
|
|
||||||
text: '90 minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 120,
|
|
||||||
text: '2 hours'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 180,
|
|
||||||
text: '3 hours'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -94,11 +66,72 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
sleepTimes() {
|
||||||
|
const times = [
|
||||||
|
{
|
||||||
|
seconds: 60 * 5,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['5']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 15,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['15']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 20,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['20']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 30,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['30']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 45,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['45']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 60,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['60']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 90,
|
||||||
|
text: this.$getString('LabelTimeDurationXMinutes', ['90']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 120,
|
||||||
|
text: this.$getString('LabelTimeDurationXHours', ['2']),
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (this.hasChapters) {
|
||||||
|
times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })
|
||||||
|
}
|
||||||
|
return times
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
submitCustomTime() {
|
||||||
|
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||||
|
this.customTime = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||||
|
const time = {
|
||||||
|
seconds: timeInSeconds,
|
||||||
|
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||||
|
}
|
||||||
|
this.setTime(time)
|
||||||
|
},
|
||||||
setTime(time) {
|
setTime(time) {
|
||||||
this.$emit('set', time.seconds)
|
this.$emit('set', time)
|
||||||
},
|
},
|
||||||
increment(amount) {
|
increment(amount) {
|
||||||
this.$emit('increment', amount)
|
this.$emit('increment', amount)
|
||||||
@@ -112,4 +145,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
<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>
|
<p class="text-lg">Preview Cover</p>
|
||||||
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||||
<div class="flex justify-center py-4">
|
<div class="flex justify-center py-4">
|
||||||
<covers-preview-cover :src="previewUpload" :width="240" />
|
<covers-preview-cover :src="previewUpload" :width="240" />
|
||||||
</div>
|
</div>
|
||||||
@@ -78,14 +78,13 @@ export default {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(data.error)
|
this.$toast.error(data.error)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success('Cover Uploaded')
|
|
||||||
this.resetCoverPreview()
|
this.resetCoverPreview()
|
||||||
}
|
}
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
@@ -95,7 +94,7 @@ export default {
|
|||||||
|
|
||||||
var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {
|
var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {
|
||||||
console.error('Failed to download cover from url', error)
|
console.error('Failed to download cover from url', error)
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -104,4 +103,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user