mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
1893 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2afd0e2acd | |||
| 0829237166 | |||
| 541975f038 | |||
| 01bf58ab97 | |||
| d99b2c25e8 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| a3e63e03d2 | |||
| 2ae3ea346f | |||
| 8542d433a2 | |||
| 03984f96d4 | |||
| eab019c577 | |||
| 179f11f55d | |||
| 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 | |||
| 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 | |||
| d0ab13865c | |||
| 33ae93e61e | |||
| 3b961c424f | |||
| 389b603d7d | |||
| 721de0a343 | |||
| 0aadf579f3 | |||
| 4ec217e5d0 | |||
| 0f01f21a0a | |||
| 46668854ad | |||
| a690dfe671 | |||
| 7528e8df41 | |||
| 8224ca7650 | |||
| a574d06e22 | |||
| dd9a072231 | |||
| 2304f37cbe | |||
| 0c20988e18 | |||
| 9a57fcad40 | |||
| 01333b6401 | |||
| 8509ca3249 | |||
| 7a69afdcd9 | |||
| 2c0c53bbf1 | |||
| 9f200ece99 | |||
| c5f91ec508 | |||
| d06c61b329 | |||
| be4f11a60e | |||
| 0c5db214d1 | |||
| 1ad9ea92b6 | |||
| d15120eb5f | |||
| b9deb32b20 | |||
| dd2d61f38e | |||
| ca2c2f2702 | |||
| 1fc929ab33 | |||
| f5495d64a9 | |||
| d6afb17bf2 | |||
| 2cba9d8f4a | |||
| e02169907d | |||
| 24a142e718 | |||
| 2cb4f972d7 | |||
| 513d946faa | |||
| 87d1f457ba | |||
| 8810f90226 | |||
| 3d3571013f | |||
| 605a6d8b25 | |||
| 1bfa4b31f2 | |||
| 7a14b49aea | |||
| 95ac74d748 | |||
| fddf850a41 | |||
| d93d4f3236 | |||
| 91f15d5a23 | |||
| 516c5c3308 | |||
| f702c02859 | |||
| ad88de0571 | |||
| b64a651b27 | |||
| 06b8d1194c | |||
| 377ae7ab19 | |||
| 53cf6edd6a | |||
| 92bedeac15 | |||
| 3cf8b9dca9 | |||
| bcc2f847f9 | |||
| f1421f351b | |||
| ed23feaf3f | |||
| 668ebf8550 | |||
| a8c7905f6d | |||
| 45cd39ac0c | |||
| 21e1f62c65 | |||
| 8416f2d6be | |||
| 3b4ac3a230 | |||
| 6244909332 | |||
| 5db949e4a7 | |||
| c453d3e8c7 | |||
| 9d7ffdfcd0 | |||
| 976427b0b3 | |||
| 6cbfd8679b | |||
| 217bbb4a8e | |||
| 9916a1e8f6 | |||
| 372101592c | |||
| 18123664ee | |||
| 2e6e4f970c | |||
| 1c9e56ce2e | |||
| 9e7b84f289 | |||
| 7b83ab8970 | |||
| 86ee4dcff2 | |||
| 277a5fa37c | |||
| 51b87912f8 | |||
| 653019921e | |||
| ccc291067d | |||
| af7e3a03f0 | |||
| 7c40d26857 | |||
| 6c507de501 | |||
| 482a4340f5 | |||
| 21e704e12c | |||
| 2b91bff1af | |||
| d11f9608b4 | |||
| 2b0b691b69 | |||
| 5dfd5c4971 | |||
| 201f1bff3e | |||
| a22ebb257f | |||
| bf6e87d4bc | |||
| b823a93ae2 | |||
| 05afd12682 | |||
| 997e23150e | |||
| 3c5bf376b5 | |||
| bca2cfda13 | |||
| 916b41d587 | |||
| ab08d83c04 | |||
| 415e0a7b5a | |||
| d301c12acd | |||
| 7aa7e662b2 | |||
| 1dbfb5637a | |||
| 4e1aacb44f | |||
| 954cf3e14e | |||
| b61ecefce4 | |||
| 8562b8d1b3 | |||
| 06ec2159f5 | |||
| 68b565505e | |||
| 83ff2752dd | |||
| d0af1c3c9a | |||
| 1ad46d4fb8 | |||
| d3dd13eae5 | |||
| f27982d887 | |||
| 624a44f572 | |||
| e623bf7fde | |||
| 6fc70b8656 | |||
| 354cefb9f4 | |||
| a78aa88dbc | |||
| 9ac2453676 | |||
| bb70800b4e | |||
| 855272a558 | |||
| ebb2c5f791 | |||
| 2e466bb164 | |||
| 95ebe0f087 | |||
| 0a6aa43b07 | |||
| 806a8cf659 | |||
| 1a32fbfeec | |||
| 67396c16dd | |||
| b0684b6f1b | |||
| 661778c02c | |||
| 5c4241aefe | |||
| 3f6bc90824 | |||
| 4ade6e04a8 | |||
| 49d0835236 | |||
| d90bd92bcc | |||
| 41c016b8c7 | |||
| 5b4d3f71f9 | |||
| 256a9322ef | |||
| 793f82e445 | |||
| ab6da3914b | |||
| 0b53f0ebf3 | |||
| 76d668514e | |||
| 3c347bef7d | |||
| e837e5f780 | |||
| 26348ccc74 | |||
| 729a756e21 | |||
| 4dbddcf179 | |||
| f2fff34d4d | |||
| 59c5e2c1d9 | |||
| 067006f406 | |||
| 93d82b973e | |||
| a9a3423b58 | |||
| f4ee215ad8 | |||
| 48431b1c35 | |||
| ce961f90ba | |||
| 916d2f6bb3 | |||
| 01e7098f00 | |||
| e02fbac4cd | |||
| a8fce32e70 | |||
| d0637c1e3d | |||
| f6702d299d | |||
| 033b7ece28 | |||
| 5f5dce6d53 | |||
| 82c5c7518b | |||
| 7a60ffb3c4 | |||
| 2795f657b5 | |||
| 9ef5b5830e | |||
| 879adfa633 | |||
| b12a344776 | |||
| 50b1098797 | |||
| fdfaa7eba4 | |||
| 5525587513 | |||
| 1f20ed7640 | |||
| f741064843 | |||
| d5138e4c0a | |||
| 42a30c33db | |||
| e5d978f8e8 | |||
| ccc82520a9 | |||
| 22acf52a26 | |||
| 2ccd2786f4 | |||
| 0028136935 | |||
| 0edc46b771 | |||
| 2261f3d1c3 | |||
| 5c0e792782 | |||
| 644882e04f | |||
| 67f51c6de9 | |||
| 0c8fd6ab0e | |||
| 5452a57a14 | |||
| 19f020e7a6 | |||
| 825641f2a9 | |||
| 35ab4cb2fe | |||
| fd13607d89 | |||
| f79b4d44b9 | |||
| 91e30a6e84 | |||
| 8ab0f164a6 | |||
| 578bb03404 | |||
| 06582b5371 | |||
| 7f6baf35b7 | |||
| 6227d0baa1 | |||
| e334b585be | |||
| c83b3f19f7 | |||
| d31ec055f9 | |||
| 38c259a45e | |||
| b2ee24de98 | |||
| 9ba0e52bb7 | |||
| edc712e6f6 | |||
| 485888b2d9 | |||
| c2e90d4d83 | |||
| f5d89b8f52 | |||
| 378b40790a | |||
| be3d38392d | |||
| 27fef50983 | |||
| 167df85c1e | |||
| 009e16c9a4 | |||
| b40cc767b2 | |||
| 4f5f2d32be | |||
| 66be3e0281 | |||
| 987f188f00 | |||
| daca2bdf2a | |||
| 8894f52439 | |||
| 863f81e55a | |||
| d03d3735e5 | |||
| 3bb2df6e12 | |||
| 80c9efc618 | |||
| 3279901ab0 | |||
| d43d351721 | |||
| 8210eba439 | |||
| cbd7294b0b | |||
| 6064e8af87 | |||
| 8754f0c25f | |||
| f31700f668 | |||
| 9877b139f6 | |||
| 5643c846ee | |||
| 5a071babe9 | |||
| 3d85d0bce6 | |||
| 68afc2c718 | |||
| b3d9323f66 | |||
| effc63755b | |||
| b90934a72a | |||
| e01748eb2f | |||
| 430fbf5e46 | |||
| 27e6b9ce0d | |||
| fc614b9833 | |||
| a97c102369 | |||
| 745a491f90 | |||
| b2880ab0a9 | |||
| f916454c55 | |||
| 701b8ea12e | |||
| 2079942ccd | |||
| 140b718592 | |||
| 2de8c72131 | |||
| 089d4b5cee | |||
| e06a015d6e | |||
| b7e546f2f5 | |||
| 26ef275ab4 | |||
| 416db7c981 | |||
| 78079b2e60 | |||
| 03bffb725a | |||
| 46fc89e247 | |||
| fbbceaa642 | |||
| f5aae25cc8 | |||
| 8d03943acb | |||
| 853513b926 | |||
| c606a41314 | |||
| 35f29ca22b | |||
| ac00f3ebe7 | |||
| 6846de98f8 | |||
| 881baa818d | |||
| b671145e73 | |||
| 8809c7b900 | |||
| ae8f3aa918 | |||
| 5d4047c171 | |||
| 6f80591afd | |||
| 9b6fa8fe8c | |||
| d6c02ebb2c | |||
| 788d867ec3 | |||
| 3bc3914fd9 | |||
| 3d821dacb7 | |||
| e0546c6164 | |||
| be7ccfb209 | |||
| 938a8c6f80 | |||
| 5cd343cb01 | |||
| ab0094a53b | |||
| 2d5e4ebcf0 | |||
| 3171ce5aba | |||
| 0e1692d26b | |||
| e8cd18eac2 | |||
| bf928692d5 | |||
| 792490b629 | |||
| 0d1ff35c5e | |||
| 67e02fddbd | |||
| 09beb6a2ae | |||
| 1bd657f07d | |||
| 2dba17a7ae | |||
| 4900649908 | |||
| c3b33ea37a | |||
| 36bd6e649a | |||
| 4621c78573 | |||
| c88bbf1ce4 | |||
| d37b25a6f6 | |||
| 792268f5ee | |||
| 5f2d6f4d5e | |||
| 1350a91fba | |||
| acf22ca4fa | |||
| 705aac40d7 | |||
| 7456052620 | |||
| 6cd4ec7fce | |||
| 93b8e11378 | |||
| 6161daeef0 | |||
| cfcd351570 | |||
| 514893646a | |||
| e5469cc0f8 | |||
| ec6e70725c | |||
| 160dac109d | |||
| 6be741045f | |||
| f41d6d5c77 | |||
| a5dacd7821 | |||
| 8b12508b0c | |||
| a394f38fe9 | |||
| c4bfa266b0 | |||
| 96232676cb | |||
| b2aab06e01 | |||
| f002532c1e | |||
| 54663f0f01 | |||
| d8df9a9dff | |||
| 68efd30a54 | |||
| 27407d49dd | |||
| 97d4330cda | |||
| 3153bdc5bb | |||
| 31fd75a895 | |||
| b22173a631 | |||
| d2e012d7b1 | |||
| d4fe0be386 | |||
| 6d947bbc29 | |||
| 5187d0e55f | |||
| c6253e4fd4 | |||
| 1ab933c8b0 | |||
| e2e5dd372a | |||
| aeb87c81a1 | |||
| 3e98b6f749 | |||
| 3c465994fe | |||
| 6cfe583535 | |||
| 0ad7a98fc7 | |||
| ce88ebb55b | |||
| c7e3f08d39 | |||
| d15264832d | |||
| a8d5b543d7 | |||
| f2e16017f6 | |||
| 4d227cbade | |||
| 15a85299b9 | |||
| d22e9e32ed | |||
| 8beac53f5f | |||
| cbad435690 | |||
| 169b637720 | |||
| f083d4b5f6 | |||
| 3451a312e9 | |||
| 927c1a3514 | |||
| dabcad5ebd | |||
| 796602d1b2 | |||
| 302870a101 | |||
| 3954aa1963 | |||
| 2d8c840ad6 | |||
| f1f02b185e | |||
| 13d21e90f8 | |||
| dd664da871 | |||
| 6ff66370fe | |||
| 23904d57ad | |||
| efdb43e2d2 | |||
| 67523095d6 | |||
| e2d869bb19 | |||
| d38e9499db | |||
| c7429efe95 | |||
| b925dbcc95 | |||
| 2a235b8324 | |||
| 06cc2a1b21 | |||
| 4bcca97b1f | |||
| 313b9026f1 | |||
| 139ee013a7 | |||
| 7e5ab477b2 | |||
| eba37c46cb | |||
| 228d9cc301 | |||
| 85946dd1d5 | |||
| b40598593d | |||
| e918a46d09 | |||
| 8061ee29d5 | |||
| e15e04f085 | |||
| 958d68ffa9 | |||
| c8a743ccc1 | |||
| 09dc95f560 | |||
| 853858825b | |||
| c962090c3a | |||
| 63a8e2433e | |||
| f78d287b59 | |||
| eaa383b6d8 | |||
| 113026ce13 | |||
| 578a946ca5 | |||
| f31306eda0 | |||
| c62b716a2c | |||
| 97ed20c683 | |||
| d5c46dcbfb | |||
| 30934edd57 | |||
| d06fd1a1b1 | |||
| 6bb36381f1 | |||
| a1331fb3f8 | |||
| 17d15144eb | |||
| 74d26eece4 | |||
| 474a7d08d0 | |||
| 639c930779 | |||
| c6323f8ad9 | |||
| caea6c6371 | |||
| d285845e04 | |||
| 5a6867e98a | |||
| 621444114f | |||
| 5591704aad | |||
| cc1181b301 | |||
| 095f49824e | |||
| b330030f50 | |||
| a7d422e23f | |||
| f51a31c8ca | |||
| 290340a385 | |||
| 0137f6dfeb | |||
| 7f27eabf3e | |||
| 4f7588c87d | |||
| a19b6370c4 | |||
| fbd7ae10d1 | |||
| f94c706fc8 | |||
| 9de4b1069a | |||
| 8fbe3c3884 | |||
| abf9120363 | |||
| 69f250cba5 | |||
| 2103edfcdc | |||
| 02ba147bd4 | |||
| 230b548921 | |||
| f34ebdc016 | |||
| 69ad651671 | |||
| edc919b3f5 | |||
| c8c7a9ece5 | |||
| 8702ac1ccf | |||
| 33833e0a36 | |||
| 6b98baafdf | |||
| cc285bb685 | |||
| ef0243f1d7 | |||
| 7a7d53f92e | |||
| 2e070227ab | |||
| 195a30096f | |||
| 55c40658f2 | |||
| db48a486e5 | |||
| d869a9836e | |||
| 55680cbc98 | |||
| 9b7e6a6058 | |||
| a482e5d316 | |||
| 5ac342defd | |||
| 944a5b3e92 | |||
| 9b9de84740 | |||
| 2746e61cb3 | |||
| 7f1d797fb2 | |||
| 2059c9f14a | |||
| 0e16a9c8de | |||
| b6a33bf7bb | |||
| ce88ac9f33 | |||
| 678dceefed | |||
| 8b38dda229 | |||
| 7373c7159b | |||
| e34a39dde4 | |||
| d4cd8c6db9 | |||
| 9e93a3c7e6 | |||
| 4a8bcc90ea | |||
| 84c12a6e7e | |||
| 2a513ac8b8 | |||
| 97687c96cd | |||
| a42c13aec2 | |||
| 5f0f8b92d1 | |||
| 78ca6aa679 | |||
| 22e3d4a150 | |||
| e3fba1fb2b | |||
| 4d95250990 | |||
| 4776368501 | |||
| 8b0ed2bf29 | |||
| 54389e3c25 | |||
| bf0da1c6ec | |||
| 591a866f8c | |||
| fc8473ed84 | |||
| b19442e440 | |||
| 7a51e0693d | |||
| 21785c8e72 | |||
| bdf6ccbd2d | |||
| ceb163570f | |||
| 049ae73d74 | |||
| 729fdd5c9f | |||
| 4dac8ac16c | |||
| 220bbc3d2d | |||
| c2a4b32192 | |||
| 09d0d47549 | |||
| 4185807da4 | |||
| 8abda14e0f | |||
| 619e5c0895 | |||
| 3a2594cde9 | |||
| 5cca2d0155 | |||
| a467637cb5 | |||
| 1a23001955 | |||
| 8942dca31d | |||
| 2a919012b6 | |||
| 40b342498f | |||
| e220b2818a | |||
| 620bf7990f | |||
| 0df36d2609 | |||
| adfe50a841 | |||
| 35925ddc1b | |||
| 33dfb764fa | |||
| 49bef2c641 | |||
| ac58536501 | |||
| c344555be3 | |||
| 645bcc53c6 | |||
| 84dd06dfc4 | |||
| 0a73dd6437 | |||
| 2cc055a1ad | |||
| d8ec3bd218 | |||
| d189ec74c9 | |||
| 4291769b93 | |||
| 22900a3f67 | |||
| 7fa08449de | |||
| 4f7203fccb | |||
| 0eea766931 | |||
| 5c054aef90 | |||
| a1674d5da1 | |||
| 91597a5454 | |||
| 11354a3e3f | |||
| dcd4f69383 | |||
| e253939c1e | |||
| f25ce1c0e7 | |||
| 7717e57c16 | |||
| 2e28c9b06d | |||
| 4bc7cd2045 | |||
| 5389115120 | |||
| 6e99cf6570 | |||
| 21bdd9f9ec | |||
| e3ae3f7e6a | |||
| 74bf917150 | |||
| 5666b263f5 | |||
| fc8fec62a0 | |||
| 034d858f18 | |||
| ebc9e1a888 | |||
| c5a9c2bf5a | |||
| 3dbce8fd71 | |||
| b2d299dba6 | |||
| cb5d9a8287 | |||
| f9530897c0 | |||
| 7c7e8285a4 | |||
| 7b3f9a1e0c | |||
| 399e0ea0bc | |||
| a47b0bce57 | |||
| 4b60b4f73e | |||
| d88b20addd | |||
| 5d12cc3f23 | |||
| 84fb7ce8b3 | |||
| 243cc672f7 | |||
| 663546dd77 | |||
| 1b79b3f42d | |||
| d4525ad5ca | |||
| dc9c307663 | |||
| 554e9ec238 | |||
| 2276228531 | |||
| 6f7d2ef4cd | |||
| ad3fbe7abf | |||
| c58110c7b7 | |||
| f781fa9e6b | |||
| 7f3543400a | |||
| 1ff5637c1b | |||
| f2d9de5a5f | |||
| 8be3bebee8 | |||
| ef88972b25 | |||
| 35f3b5863f | |||
| ff294867f8 | |||
| 1c6cd7499b | |||
| ce35ae6b03 | |||
| 28c99cf17f | |||
| 584e754eae | |||
| 68cf748e77 | |||
| 9b8f53caf6 | |||
| fdf332937f | |||
| 182545a729 | |||
| e83df2bf4b | |||
| 10299e3037 | |||
| 6a43672973 | |||
| 02bf55b401 | |||
| f0615c2971 | |||
| 7ef44eb75b | |||
| 044804115b | |||
| 3b941d59a3 | |||
| d69f6020c6 | |||
| 2fc60e4e9c | |||
| cdcfd01da2 | |||
| d6c5b6e8c6 | |||
| 5d305c96ad | |||
| 6d823f4e42 | |||
| bd5e865a11 | |||
| cd274e0844 | |||
| e9249430c3 | |||
| cd5e5099f2 | |||
| 09dd90e3fc | |||
| a62f7a4861 | |||
| 5a26b01ffb | |||
| cbde451120 | |||
| 8bbeae4873 | |||
| 05dff2583a | |||
| 79a82df914 | |||
| 3f6ed6dbf9 | |||
| 4edba20e9e | |||
| 2c6e1cc2b5 | |||
| e1af25d9d8 | |||
| 9b30a8ff4b | |||
| b1a9de819e | |||
| 68da974c12 | |||
| 8c47ccb651 | |||
| d544ecc657 | |||
| 9f69a8ace3 | |||
| a90cfc4d04 | |||
| 88354de495 | |||
| 5b02c5185f | |||
| 1152e5513e | |||
| 8ce9b55969 | |||
| ccf08e9e80 | |||
| b0b1d2707d | |||
| 469278cd1e | |||
| 10d9e11387 | |||
| 5328f4cddb | |||
| 4154022ad1 | |||
| 642e9787c0 | |||
| da2e65c042 | |||
| ab895fa8ed | |||
| f5e892b862 | |||
| ac097862fc | |||
| 23cc6bb210 | |||
| c60807f998 | |||
| 99e2ea228d | |||
| 8df05896b5 | |||
| 174dac8fd4 | |||
| 2a386ca2a9 | |||
| fc228013d3 | |||
| 64b824ef6b | |||
| 96cd91a385 | |||
| 5c91c1e2c7 | |||
| 2df5ab0dde | |||
| baf738f5ba | |||
| 3a7cafbb95 | |||
| 3276b04256 | |||
| ac3fa31d1e | |||
| 6e5e638076 | |||
| 609bf4309f | |||
| 66b5c14c6b | |||
| e4936ed522 | |||
| c201e2aa98 | |||
| 3d3f20296c | |||
| 9ae71615bc | |||
| 292840a0e3 | |||
| 84e6e6fdbe | |||
| cfe27dff80 | |||
| c75895d711 | |||
| c0ff28ffff | |||
| 58dfa65660 | |||
| 3f8e685d64 | |||
| 08e1782253 | |||
| 0dd219f303 | |||
| d5e96a3422 | |||
| 03bfecefee | |||
| 12027b9a76 | |||
| 0e665e2091 | |||
| e32d05ea27 | |||
| 5446aea910 | |||
| 86e7c7fc33 | |||
| 173b72c3b5 | |||
| 3150822117 | |||
| 9a96d17a30 | |||
| c98409b9ae | |||
| 0e3640c246 | |||
| e030b59bae | |||
| 920ca683b9 | |||
| 28d76d21f1 | |||
| e1e6b46456 | |||
| 122f2a2556 | |||
| 27f1bd90f9 | |||
| f8d0384155 | |||
| 43bbfbfee3 | |||
| deadc63dbb | |||
| a9b9e23f46 | |||
| 6a06ba4327 | |||
| 3d2bbc7719 | |||
| c9ea5dd2d7 | |||
| eea3e2583c | |||
| 57399bb79e | |||
| 69fcb103e4 | |||
| f00b120e96 | |||
| 14a8f84446 | |||
| 099ae7c776 | |||
| 1cf9e85272 | |||
| c4eeb1cfb7 | |||
| 1dde02b170 | |||
| 08e648a3bc | |||
| 755e70b4a9 | |||
| 5ff4cd2c0b | |||
| e36c31c5e7 | |||
| d561a48229 | |||
| 5243a225e8 | |||
| 4fe60465e5 | |||
| 0af6ad63c1 | |||
| 68b13ae45f | |||
| 4c2ad3ede5 | |||
| deea6702f0 | |||
| 7348432594 | |||
| 7d66f1eec9 | |||
| be1e1e7ba0 | |||
| 4bdef893af | |||
| 6597fca576 | |||
| ea9ec13845 | |||
| 30f15d3575 | |||
| dad12537b6 | |||
| 65df377a49 | |||
| 2d19208340 | |||
| 73257188f6 | |||
| 5f4e5cd3d8 | |||
| f2be3bc95e | |||
| 2a30cc428f | |||
| b97ed953f7 | |||
| 65793f7109 | |||
| 2b7f53b0a7 | |||
| c6eb1096e8 | |||
| a907c88f66 | |||
| 43f48b65f8 | |||
| 2a4cbd48b8 | |||
| b6e4f3a8c5 | |||
| 83976b5549 |
@@ -0,0 +1,15 @@
|
|||||||
|
# [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
|
||||||
|
ARG VARIANT=16
|
||||||
|
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||||
|
|
||||||
|
# Setup the node environment
|
||||||
|
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/*
|
||||||
|
|
||||||
|
# Move tone executable to appropriate directory
|
||||||
|
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// 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'
|
||||||
|
}
|
||||||
@@ -0,0 +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
|
||||||
|
{
|
||||||
|
"name": "Audiobookshelf",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||||
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
|
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
|
"args": {
|
||||||
|
"VARIANT": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
"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,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
|
||||||
@@ -9,6 +9,12 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
@@ -27,6 +33,7 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Audiobookshelf version
|
label: Audiobookshelf version
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/pJsjuNCKRq
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
|
- name: Matrix
|
||||||
|
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Allows you to run workflow manually from Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tags:
|
||||||
|
description: 'Docker Tag'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
|
push:
|
||||||
|
branches: [main,master]
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
# Only build when files in these directories have been changed
|
||||||
|
paths:
|
||||||
|
- client/**
|
||||||
|
- server/**
|
||||||
|
- index.js
|
||||||
|
- package.json
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=master
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Login to Dockerhub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to ghcr
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|
||||||
|
- name: Move cache
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/.buildx-cache
|
||||||
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
@@ -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: 16
|
||||||
|
|
||||||
|
- name: install pkg
|
||||||
|
run: npm install -g 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 node18-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
|
||||||
+6
-3
@@ -1,14 +1,17 @@
|
|||||||
.env
|
.env
|
||||||
dev.js
|
/dev.js
|
||||||
node_modules/
|
**/node_modules/
|
||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
test/
|
test/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
/deploy/
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
|
.DS_STORE
|
||||||
|
|||||||
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
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+27
-9
@@ -1,19 +1,37 @@
|
|||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:12-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm install
|
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:12-alpine
|
FROM sandreas/tone:v0.1.5 AS tone
|
||||||
RUN apk update && apk add --no-cache --update ffmpeg
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
RUN apk update && \
|
||||||
|
apk add --no-cache --update \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
ffmpeg \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
g++
|
||||||
|
|
||||||
|
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js index.js
|
COPY index.js package* /
|
||||||
COPY package-lock.json package-lock.json
|
|
||||||
COPY package.json package.json
|
|
||||||
COPY server server
|
COPY server server
|
||||||
RUN npm ci --production
|
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["npm", "start"]
|
HEALTHCHECK \
|
||||||
|
--interval=30s \
|
||||||
|
--timeout=3s \
|
||||||
|
--start-period=10s \
|
||||||
|
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||||
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -2,49 +2,11 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
ABS_LOG_DIR="/var/log/audiobookshelf"
|
||||||
|
|
||||||
declare -r init_type='auto'
|
declare -r init_type='auto'
|
||||||
declare -ri no_rebuild='0'
|
declare -ri no_rebuild='0'
|
||||||
|
|
||||||
add_user() {
|
|
||||||
: "${1:?'User was not defined'}"
|
|
||||||
declare -r user="$1"
|
|
||||||
declare -r uid="$2"
|
|
||||||
|
|
||||||
if [ -z "$uid" ]; then
|
|
||||||
declare -r uid_flags=""
|
|
||||||
else
|
|
||||||
declare -r uid_flags="--uid $uid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -r group="${3:-$user}"
|
|
||||||
declare -r descr="${4:-No description}"
|
|
||||||
declare -r shell="${5:-/bin/false}"
|
|
||||||
|
|
||||||
if ! getent passwd | grep -q "^$user:"; then
|
|
||||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
|
||||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_group() {
|
|
||||||
: "${1:?'Group was not defined'}"
|
|
||||||
declare -r group="$1"
|
|
||||||
declare -r gid="$2"
|
|
||||||
|
|
||||||
if [ -z "$gid" ]; then
|
|
||||||
declare -r gid_flags=""
|
|
||||||
else
|
|
||||||
declare -r gid_flags="--gid $gid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! getent group | grep -q "^$group:" ; then
|
|
||||||
echo "Creating system group: $group"
|
|
||||||
groupadd $gid_flags --system $group
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
start_service () {
|
start_service () {
|
||||||
: "${1:?'Service name was not defined'}"
|
: "${1:?'Service name was not defined'}"
|
||||||
declare -r service_name="$1"
|
declare -r service_name="$1"
|
||||||
@@ -76,13 +38,10 @@ start_service () {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create log directory if not there and set ownership
|
||||||
add_group 'audiobookshelf' ''
|
if [ ! -d "$ABS_LOG_DIR" ]; then
|
||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
mkdir -p "$ABS_LOG_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
|
||||||
mkdir -p '/var/log/audiobookshelf'
|
fi
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
|
||||||
|
|
||||||
start_service 'audiobookshelf'
|
start_service 'audiobookshelf'
|
||||||
|
|||||||
+78
-79
@@ -2,21 +2,60 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
|
||||||
DEFAULT_PORT=7331
|
|
||||||
|
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
|
DEFAULT_PORT=13378
|
||||||
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
|
add_user() {
|
||||||
|
: "${1:?'User was not defined'}"
|
||||||
|
declare -r user="$1"
|
||||||
|
declare -r uid="$2"
|
||||||
|
|
||||||
|
if [ -z "$uid" ]; then
|
||||||
|
declare -r uid_flags=""
|
||||||
|
else
|
||||||
|
declare -r uid_flags="--uid $uid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -r group="${3:-$user}"
|
||||||
|
declare -r descr="${4:-No description}"
|
||||||
|
declare -r shell="${5:-/bin/false}"
|
||||||
|
|
||||||
|
if ! getent passwd | grep -q "^$user:"; then
|
||||||
|
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||||
|
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_group() {
|
||||||
|
: "${1:?'Group was not defined'}"
|
||||||
|
declare -r group="$1"
|
||||||
|
declare -r gid="$2"
|
||||||
|
|
||||||
|
if [ -z "$gid" ]; then
|
||||||
|
declare -r gid_flags=""
|
||||||
|
else
|
||||||
|
declare -r gid_flags="--gid $gid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent group | grep -q "^$group:" ; then
|
||||||
|
echo "Creating system group: $group"
|
||||||
|
groupadd $gid_flags --system $group
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
install_ffmpeg() {
|
install_ffmpeg() {
|
||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
|
|
||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||||
|
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
mkdir "$FFMPEG_INSTALL_DIR"
|
mkdir "$FFMPEG_INSTALL_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||||
cd "$FFMPEG_INSTALL_DIR"
|
cd "$FFMPEG_INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -24,88 +63,44 @@ install_ffmpeg() {
|
|||||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg... hopefully"
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
}
|
echo "Getting tone.."
|
||||||
|
$WGET_TONE
|
||||||
|
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
||||||
|
rm tone-0.1.5-linux-x64.tar.gz
|
||||||
|
|
||||||
should_build_config() {
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
|
||||||
echo "You already have a config file. Do you want to use it?"
|
|
||||||
|
|
||||||
options=("Yes" "No")
|
|
||||||
select yn in "${options[@]}"
|
|
||||||
do
|
|
||||||
case $yn in
|
|
||||||
"Yes")
|
|
||||||
false; return
|
|
||||||
;;
|
|
||||||
"No")
|
|
||||||
true; return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No existing config found in $CONFIG_PATH"
|
|
||||||
true; return
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config_interactive() {
|
|
||||||
if should_build_config; then
|
|
||||||
echo "Okay, let's setup a new config."
|
|
||||||
|
|
||||||
AUDIOBOOK_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
|
||||||
|
|
||||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
|
||||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATA_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
|
||||||
|
|
||||||
if [[ -z "$DATA_PATH" ]]; then
|
|
||||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=""
|
|
||||||
read -p "
|
|
||||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
|
||||||
PORT="$DEFAULT_PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
|
||||||
METADATA_PATH=$DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
|
||||||
PORT=$PORT"
|
|
||||||
|
|
||||||
echo "$config_text"
|
|
||||||
|
|
||||||
echo "$config_text" > /etc/default/audiobookshelf;
|
|
||||||
|
|
||||||
echo "Config created"
|
|
||||||
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
|
|
||||||
|
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
||||||
|
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
||||||
|
echo "Adding TONE_PATH to existing config"
|
||||||
|
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
|
# Create directory and set permissions
|
||||||
|
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||||
|
mkdir "$DEFAULT_DATA_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||||
PORT=$DEFAULT_PORT"
|
PORT=$DEFAULT_PORT
|
||||||
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
@@ -115,6 +110,10 @@ setup_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_group 'audiobookshelf' ''
|
||||||
|
|
||||||
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
setup_config
|
||||||
|
|
||||||
install_ffmpeg
|
install_ffmpeg
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# Package debian
|
||||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||||
|
|
||||||
fakeroot dpkg-deb --build dist/debian
|
fakeroot dpkg-deb --build dist/debian
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
+94
-22
@@ -1,6 +1,8 @@
|
|||||||
@import './fonts.css';
|
@import './fonts.css';
|
||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@import './draggable.css';
|
@import './draggable.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);
|
||||||
@@ -12,18 +14,34 @@
|
|||||||
height: calc(100% - 64px);
|
height: calc(100% - 64px);
|
||||||
max-height: calc(100% - 64px);
|
max-height: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page.streaming {
|
.page.streaming {
|
||||||
height: calc(100% - 64px - 165px);
|
height: calc(100% - 64px - 165px);
|
||||||
max-height: calc(100% - 64px - 165px);
|
max-height: calc(100% - 64px - 165px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
/* Sidebar width + scrollbar width */
|
||||||
|
width: calc(100vw - 88px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-wrapper {
|
#page-wrapper {
|
||||||
@@ -34,36 +52,25 @@
|
|||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar:horizontal {
|
::-webkit-scrollbar:horizontal {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar:horizontal { */
|
|
||||||
/* height: 16px; */
|
|
||||||
/* height: 24px;
|
|
||||||
} */
|
|
||||||
/* Track */
|
/* Track */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: rgba(0,0,0,0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-track:horizontal { */
|
|
||||||
/* background: rgb(149, 119, 90); */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
} */
|
|
||||||
/* Handle */
|
/* Handle */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #855620;
|
background: #855620;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
border-radius: 4px;
|
|
||||||
} */
|
|
||||||
/* Handle on hover */
|
/* Handle on hover */
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #704922;
|
background: #704922;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-scroll::-webkit-scrollbar {
|
.no-scroll::-webkit-scrollbar {
|
||||||
@@ -71,6 +78,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-scroll {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
/* Chrome, Safari, Edge, Opera */
|
/* Chrome, Safari, Edge, Opera */
|
||||||
.no-spinner::-webkit-outer-spin-button,
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
.no-spinner::-webkit-inner-spin-button {
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
@@ -89,18 +103,23 @@ input[type=number] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #474747;
|
border: 1px solid #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
.tracksTable tr:nth-child(even) {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr {
|
.tracksTable tr {
|
||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
.tracksTable tr:hover {
|
|
||||||
|
.tracksTable tr:hover:not(:has(th)) {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -113,13 +132,22 @@ input[type=number] {
|
|||||||
border-right: 6px solid transparent;
|
border-right: 6px solid transparent;
|
||||||
border-top: 6px solid white;
|
border-top: 6px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-down-small {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 4px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.triangle-right {
|
.triangle-right {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
border-bottom: 8px solid transparent;
|
border-bottom: 8px solid transparent;
|
||||||
border-top: 8px solid rgb(34,127,35);
|
border-top: 8px solid rgb(34, 127, 35);
|
||||||
border-right: 8px solid rgb(34,127,35);
|
border-right: 8px solid rgb(34, 127, 35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text {
|
.icon-text {
|
||||||
@@ -149,6 +177,7 @@ input[type=number] {
|
|||||||
.box-shadow-book {
|
.box-shadow-book {
|
||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -165,9 +194,9 @@ input[type=number] {
|
|||||||
Bookshelf Label
|
Bookshelf Label
|
||||||
*/
|
*/
|
||||||
.categoryPlacard {
|
.categoryPlacard {
|
||||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shinyBlack {
|
.shinyBlack {
|
||||||
background-color: #2d3436;
|
background-color: #2d3436;
|
||||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||||
@@ -187,3 +216,46 @@ Bookshelf Label
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-subtitle-long {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-height: 16px;
|
||||||
|
/* fallback */
|
||||||
|
max-height: 72px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 6;
|
||||||
|
/* number of lines to show */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-bars .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
This is for setting regular html styles for places where embedding HTML will be
|
||||||
|
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
.default-style p {
|
||||||
|
display: block;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #5985ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ul {
|
||||||
|
display: block;
|
||||||
|
list-style: circle;
|
||||||
|
list-style-type: disc;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ol {
|
||||||
|
display: block;
|
||||||
|
list-style: decimal;
|
||||||
|
list-style-type: decimal;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li {
|
||||||
|
display: list-item;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li::marker {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-transform: none;
|
||||||
|
text-indent: 0px !important;
|
||||||
|
text-align: start !important;
|
||||||
|
text-align-last: start !important;
|
||||||
|
}
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
.flip-list-move {
|
.flip-list-move {
|
||||||
transition: transform 0.5s;
|
transition: transform 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-move {
|
.no-move {
|
||||||
transition: transform 0s;
|
transition: transform 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
background-color: rgba(255, 255, 255, 0.25);
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group {
|
.list-group {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
#librariesTable .item {
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.exclude) {
|
.list-group-item:not(.exclude) {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude {
|
.list-group-item.exclude {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
.list-group-item.exclude:not(.ghost) {
|
.list-group-item.exclude:not(.ghost) {
|
||||||
background-color: rgba(255, 0, 0, 0.25);
|
background-color: rgba(255, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude:not(.ghost):hover {
|
.list-group-item.exclude:not(.ghost):hover {
|
||||||
background-color: rgba(223, 0, 0, 0.25);
|
background-color: rgba(223, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
+265
-13
@@ -1,15 +1,15 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons {
|
.material-icons {
|
||||||
@@ -23,12 +23,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
|
||||||
|
.material-icons:not([class*="text-"]) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons-outlined {
|
.material-icons-outlined {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -40,28 +41,279 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
|
||||||
|
.material-icons-outlined:not([class*="text-"]) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gentium Book Basic';
|
font-family: 'Source Sans Pro';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* latin */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gentium Book Basic';
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Trix 1.3.1
|
||||||
|
Copyright © 2020 Basecamp, LLC
|
||||||
|
http://trix-editor.org/*/
|
||||||
|
trix-editor {
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
min-height: 5em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-top-color: rgb(75, 85, 99);
|
||||||
|
border-bottom-color: rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 1.5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid rgb(75, 85, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button.trix-active {
|
||||||
|
background: #bbb;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:disabled {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
font-size: inherit;
|
||||||
|
width: 2.6em;
|
||||||
|
height: 1.6em;
|
||||||
|
max-width: calc(0.8em + 4vw);
|
||||||
|
text-indent: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
height: 2em;
|
||||||
|
max-width: calc(0.8em + 3.5vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
content: "";
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
right: 6%;
|
||||||
|
left: 6%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon.trix-active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon:disabled::before {
|
||||||
|
opacity: 0.125;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-attach::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
top: 8%;
|
||||||
|
bottom: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bold::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-italic::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-link::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-strike::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-quote::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-heading-1::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-code::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-number-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-undo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-redo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialogs {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 15px 10px;
|
||||||
|
background: rgb(48, 48, 48);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid rgb(112, 112, 112);
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 0.5em 0.8em;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
background-color: rgb(95, 95, 95);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||||
|
box-shadow: #F00 0px 0px 1.5px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-bottom: none;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog--link {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||||
|
flex: 0 0 content;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::-moz-selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::-moz-selection,
|
||||||
|
trix-editor [data-trix-mutable] ::-moz-selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::selection,
|
||||||
|
trix-editor [data-trix-mutable] ::selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment img {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment:hover {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment--preview .attachment__caption:hover {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
height: 20px;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
left: 5%;
|
||||||
|
width: 90%;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress[value="100"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__caption-editor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__toolbar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: -0.9em;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button-group {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 80%;
|
||||||
|
padding: 0 0.8em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button.trix-active {
|
||||||
|
background: #cbeefa;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove {
|
||||||
|
text-indent: -9999px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
width: 1.8em;
|
||||||
|
height: 1.8em;
|
||||||
|
line-height: 1.8em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 2px solid highlight;
|
||||||
|
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
content: "";
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 2em;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
max-width: 90%;
|
||||||
|
padding: 0.1em 0.6em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__size {
|
||||||
|
margin-left: 0.2em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content h1 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content blockquote {
|
||||||
|
border: 0 solid #ccc;
|
||||||
|
border-left-width: 0.3em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
padding-left: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] blockquote,
|
||||||
|
.trix-content blockquote[dir=rtl] {
|
||||||
|
border-width: 0;
|
||||||
|
border-right-width: 0.3em;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
padding-right: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content li {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] li {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content pre {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.5em;
|
||||||
|
white-space: pre;
|
||||||
|
background-color: #eee;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a:hover,
|
||||||
|
.trix-content .attachment a:visited:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
|
||||||
|
content: ' · ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview .attachment__caption {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--file {
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 2px 2px 2px;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery .attachment {
|
||||||
|
flex: 1 0 33%;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
max-width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||||
|
flex-basis: 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full -mt-6">
|
|
||||||
<div class="w-full relative mb-1">
|
|
||||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
|
||||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
|
||||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
|
||||||
<div v-else class="flex items-center">
|
|
||||||
<span class="material-icons text-lg text-warning">snooze</span>
|
|
||||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
|
||||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
|
||||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<template v-if="!loading">
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
|
||||||
<span class="material-icons text-3xl">first_page</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
|
||||||
<span class="material-icons text-3xl">replay_10</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
|
||||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
|
||||||
<span class="material-icons text-3xl">forward_10</span>
|
|
||||||
</div>
|
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
|
||||||
<span class="material-icons">autorenew</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<!-- Track -->
|
|
||||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
|
||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
|
||||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
|
||||||
</div>
|
|
||||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
|
||||||
<template v-for="(tick, index) in chapterTicks">
|
|
||||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hover timestamp -->
|
|
||||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
|
||||||
</div>
|
|
||||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
|
||||||
<div class="arrow-down" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
loading: Boolean,
|
|
||||||
paused: Boolean,
|
|
||||||
chapters: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
bookmarks: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
sleepTimerSet: Boolean,
|
|
||||||
sleepTimerRemaining: Number
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
volume: 1,
|
|
||||||
playbackRate: 1,
|
|
||||||
trackWidth: 0,
|
|
||||||
playedTrackWidth: 0,
|
|
||||||
bufferTrackWidth: 0,
|
|
||||||
readyTrackWidth: 0,
|
|
||||||
audioEl: null,
|
|
||||||
seekLoading: false,
|
|
||||||
showChaptersModal: false,
|
|
||||||
currentTime: 0,
|
|
||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
|
||||||
duration: 0,
|
|
||||||
chapterTicks: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
sleepTimerRemainingString() {
|
|
||||||
var rounded = Math.round(this.sleepTimerRemaining)
|
|
||||||
if (rounded < 90) {
|
|
||||||
return `${rounded}s`
|
|
||||||
}
|
|
||||||
var minutesRounded = Math.round(rounded / 60)
|
|
||||||
if (minutesRounded < 90) {
|
|
||||||
return `${minutesRounded}m`
|
|
||||||
}
|
|
||||||
var hoursRounded = Math.round(minutesRounded / 60)
|
|
||||||
return `${hoursRounded}h`
|
|
||||||
},
|
|
||||||
token() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
timeRemaining() {
|
|
||||||
return (this.duration - this.currentTime) / this.playbackRate
|
|
||||||
},
|
|
||||||
timeRemainingPretty() {
|
|
||||||
if (this.timeRemaining < 0) {
|
|
||||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
|
||||||
}
|
|
||||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
|
||||||
},
|
|
||||||
progressPercent() {
|
|
||||||
if (!this.duration) return 0
|
|
||||||
return Math.round((100 * this.currentTime) / this.duration)
|
|
||||||
},
|
|
||||||
currentChapter() {
|
|
||||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
|
||||||
},
|
|
||||||
currentChapterName() {
|
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setDuration(duration) {
|
|
||||||
this.duration = duration
|
|
||||||
|
|
||||||
this.chapterTicks = this.chapters.map((chap) => {
|
|
||||||
var perc = chap.start / this.duration
|
|
||||||
return {
|
|
||||||
title: chap.title,
|
|
||||||
left: perc * this.trackWidth
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
setCurrentTime(time) {
|
|
||||||
this.currentTime = time
|
|
||||||
this.updateTimestamp()
|
|
||||||
this.updatePlayedTrack()
|
|
||||||
},
|
|
||||||
playPause() {
|
|
||||||
this.$emit('playPause')
|
|
||||||
},
|
|
||||||
jumpBackward() {
|
|
||||||
this.$emit('jumpBackward')
|
|
||||||
},
|
|
||||||
jumpForward() {
|
|
||||||
this.$emit('jumpForward')
|
|
||||||
},
|
|
||||||
increaseVolume() {
|
|
||||||
if (this.volume >= 1) return
|
|
||||||
this.volume = Math.min(1, this.volume + 0.1)
|
|
||||||
this.setVolume(this.volume)
|
|
||||||
},
|
|
||||||
decreaseVolume() {
|
|
||||||
if (this.volume <= 0) return
|
|
||||||
this.volume = Math.max(0, this.volume - 0.1)
|
|
||||||
this.setVolume(this.volume)
|
|
||||||
},
|
|
||||||
setVolume(volume) {
|
|
||||||
this.$emit('setVolume', volume)
|
|
||||||
},
|
|
||||||
toggleMute() {
|
|
||||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
|
||||||
this.$refs.volumeControl.toggleMute()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
increasePlaybackRate() {
|
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
|
||||||
if (currentRateIndex >= rates.length - 1) return
|
|
||||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
|
||||||
decreasePlaybackRate() {
|
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
|
||||||
if (currentRateIndex <= 0) return
|
|
||||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
|
||||||
setPlaybackRate(playbackRate) {
|
|
||||||
this.$emit('setPlaybackRate', playbackRate)
|
|
||||||
},
|
|
||||||
selectChapter(chapter) {
|
|
||||||
this.seek(chapter.start)
|
|
||||||
this.showChaptersModal = false
|
|
||||||
},
|
|
||||||
seek(time) {
|
|
||||||
this.$emit('seek', time)
|
|
||||||
},
|
|
||||||
playbackRateUpdated(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
},
|
|
||||||
playbackRateChanged(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
|
||||||
console.error('Failed to update settings', err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
mousemoveTrack(e) {
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var time = (offsetX / this.trackWidth) * this.duration
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
var width = this.$refs.hoverTimestamp.clientWidth
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 1
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
|
||||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
|
||||||
} else if (posLeft < -this.trackOffsetLeft) {
|
|
||||||
posLeft = -this.trackOffsetLeft
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
|
||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampText) {
|
|
||||||
var hoverText = this.$secondsToTimestamp(time)
|
|
||||||
|
|
||||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
|
||||||
if (chapter && chapter.title) {
|
|
||||||
hoverText += ` - ${chapter.title}`
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestampText.innerText = hoverText
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 1
|
|
||||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mouseleaveTrack() {
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restart() {
|
|
||||||
this.seek(0)
|
|
||||||
},
|
|
||||||
setStreamReady() {
|
|
||||||
this.readyTrackWidth = this.trackWidth
|
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
|
||||||
},
|
|
||||||
setChunksReady(chunks, numSegments) {
|
|
||||||
var largestSeg = 0
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
var chunk = chunks[i]
|
|
||||||
if (typeof chunk === 'string') {
|
|
||||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
|
||||||
if (chunkRange.length < 2) continue
|
|
||||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
|
||||||
} else if (chunk > largestSeg) {
|
|
||||||
largestSeg = chunk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var percentageReady = largestSeg / numSegments
|
|
||||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
|
||||||
if (this.readyTrackWidth === widthReady) return
|
|
||||||
this.readyTrackWidth = widthReady
|
|
||||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
|
||||||
},
|
|
||||||
updateTimestamp() {
|
|
||||||
var ts = this.$refs.currentTimestamp
|
|
||||||
if (!ts) {
|
|
||||||
console.error('No timestamp el')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
|
||||||
ts.innerText = currTimeClean
|
|
||||||
},
|
|
||||||
updatePlayedTrack() {
|
|
||||||
var perc = this.currentTime / this.duration
|
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
|
||||||
this.playedTrackWidth = ptWidth
|
|
||||||
},
|
|
||||||
clickTrack(e) {
|
|
||||||
if (this.loading) return
|
|
||||||
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var perc = offsetX / this.trackWidth
|
|
||||||
var time = perc * this.duration
|
|
||||||
if (isNaN(time) || time === null) {
|
|
||||||
console.error('Invalid time', perc, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.seek(time)
|
|
||||||
},
|
|
||||||
setBufferTime(bufferTime) {
|
|
||||||
if (!this.audioEl) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
|
||||||
bufferlen = Math.round(bufferlen)
|
|
||||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
|
||||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
|
||||||
this.bufferTrackWidth = bufferlen
|
|
||||||
},
|
|
||||||
showChapters() {
|
|
||||||
if (!this.chapters.length) return
|
|
||||||
this.showChaptersModal = !this.showChaptersModal
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
|
||||||
this.$emit('setPlaybackRate', this.playbackRate)
|
|
||||||
this.setTrackWidth()
|
|
||||||
},
|
|
||||||
setTrackWidth() {
|
|
||||||
if (this.$refs.track) {
|
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
|
||||||
} else {
|
|
||||||
console.error('Track not loaded', this.$refs)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
settingsUpdated(settings) {
|
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
|
||||||
this.setPlaybackRate(settings.playbackRate)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
closePlayer() {
|
|
||||||
if (this.loading) return
|
|
||||||
this.$emit('close')
|
|
||||||
},
|
|
||||||
hotkey(action) {
|
|
||||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
|
||||||
},
|
|
||||||
windowResize() {
|
|
||||||
this.setTrackWidth()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
window.addEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
|
||||||
this.init()
|
|
||||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
window.removeEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
|
||||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.loadingTrack {
|
|
||||||
animation-name: loadingTrack;
|
|
||||||
animation-duration: 1s;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
}
|
|
||||||
@keyframes loadingTrack {
|
|
||||||
0% {
|
|
||||||
left: -25%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,63 +1,83 @@
|
|||||||
<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">
|
||||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
<nuxt-link to="/">
|
||||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
<img src="~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" />
|
||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
</nuxt-link>
|
||||||
</a>
|
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
|
||||||
|
|
||||||
<ui-libraries-dropdown />
|
<nuxt-link to="/">
|
||||||
|
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<controls-global-search class="hidden md:block" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
|
|
||||||
|
<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-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-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-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 to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<widgets-notification-widget class="hidden md:block" />
|
||||||
<span class="material-icons">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-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||||
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">upload</span>
|
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||||
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">settings</span>
|
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</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-icons text-xl text-gray-100">person</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="numAudiobooksSelected" 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">{{ numAudiobooksSelected }} Selected</h1>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
<span class="material-icons 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-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="userCanUpdate" 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 && numAudiobooksSelected < 50">
|
<template v-if="userCanUpdate">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
|
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<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-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,9 +87,7 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processingBatchDelete: false,
|
totalEntities: 0
|
||||||
totalEntities: 0,
|
|
||||||
isAllSelected: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -79,32 +97,38 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||||
},
|
},
|
||||||
|
libraryMediaType() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.libraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.libraryMediaType === 'book'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
|
||||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
|
||||||
},
|
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numAudiobooksSelected() {
|
numMediaItemsSelected() {
|
||||||
return this.selectedAudiobooks.length
|
return this.selectedMediaItems.length
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.selectedAudiobooks
|
return this.$store.state.globals.selectedMediaItems
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
selectedMediaItemsArePlayable() {
|
||||||
return this.$store.state.user.user.audiobooks || {}
|
return !this.selectedMediaItems.some((i) => !i.hasTracks)
|
||||||
},
|
},
|
||||||
selectedSeries() {
|
userMediaProgress() {
|
||||||
return this.$store.state.audiobooks.selectedSeries
|
return this.$store.state.user.user.mediaProgress || []
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
@@ -115,19 +139,16 @@ export default {
|
|||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
selectedIsRead() {
|
selectedIsFinished() {
|
||||||
// Find an audiobook that is not read, if none then all audiobooks read
|
// Find an item that is not finished, if none then all items finished
|
||||||
return !this.selectedAudiobooks.find((ab) => {
|
return !this.selectedMediaItems.find((item) => {
|
||||||
var userAb = this.userAudiobooks[ab]
|
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
|
||||||
return !userAb || !userAb.isRead
|
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')
|
||||||
},
|
},
|
||||||
@@ -136,80 +157,207 @@ 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: 'Quick Embed Metadata',
|
||||||
|
action: 'quick-embed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
text: 'Re-Scan',
|
||||||
|
action: 'rescan'
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleBookshelfTexture() {
|
requestBatchQuickEmbed() {
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
const payload = {
|
||||||
|
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
async back() {
|
contextMenuAction({ action }) {
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
if (action === 'quick-embed') {
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
this.requestBatchQuickEmbed()
|
||||||
var backTo = popped || '/'
|
} else if (action === 'quick-match') {
|
||||||
this.$router.push(backTo)
|
this.batchAutoMatchClick()
|
||||||
|
} else if (action === 'rescan') {
|
||||||
|
this.batchRescan()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async batchRescan() {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||||
|
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('setSelectedAudiobooks', [])
|
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 newIsRead = !this.selectedIsRead
|
const newIsFinished = !this.selectedIsFinished
|
||||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
|
||||||
return {
|
return {
|
||||||
audiobookId: ab,
|
libraryItemId: item.id,
|
||||||
isRead: newIsRead
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/audiobook/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('setSelectedAudiobooks', [])
|
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.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
const payload = {
|
||||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||||
if (confirm(confirmMsg)) {
|
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||||
this.processingBatchDelete = true
|
yesButtonText: this.$strings.ButtonDelete,
|
||||||
this.$store.commit('setProcessingBatch', true)
|
yesButtonColor: 'error',
|
||||||
this.$axios
|
checkboxDefaultValue: true,
|
||||||
.$post(`/api/books/batch/delete`, {
|
callback: (confirmed, hardDelete) => {
|
||||||
audiobookIds: this.selectedAudiobooks
|
if (confirmed) {
|
||||||
})
|
this.$store.commit('setProcessingBatch', true)
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Batch delete success!')
|
this.$axios
|
||||||
this.processingBatchDelete = false
|
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||||
this.$store.commit('setProcessingBatch', false)
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
})
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
.then(() => {
|
||||||
})
|
this.$toast.success('Batch delete success')
|
||||||
.catch((error) => {
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$toast.error('Batch delete failed')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
console.error('Failed to batch delete', error)
|
})
|
||||||
this.processingBatchDelete = false
|
.catch((error) => {
|
||||||
this.$store.commit('setProcessingBatch', false)
|
console.error('Batch delete failed', error)
|
||||||
})
|
this.$toast.error('Batch delete failed')
|
||||||
|
})
|
||||||
|
.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() {
|
||||||
@@ -225,4 +373,4 @@ export default {
|
|||||||
#appbar {
|
#appbar {
|
||||||
box-shadow: 0px 5px 5px #11111155;
|
box-shadow: 0px 5px 5px #11111155;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="outer-container">
|
|
||||||
<!-- absolute positioned container -->
|
|
||||||
<div class="inner-container">
|
|
||||||
<div class="relative h-10">
|
|
||||||
<div class="table-header" id="headerdiv">
|
|
||||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="header-cell min-w-12 max-w-12"></th>
|
|
||||||
<th class="header-cell min-w-6 max-w-6"></th>
|
|
||||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
|
||||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
|
||||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<tbody>
|
|
||||||
<template v-for="book in books">
|
|
||||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
books: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isScrollable: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
checkIsScrolled() {
|
|
||||||
if (!this.$refs.tableBody) return
|
|
||||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
|
||||||
},
|
|
||||||
tableScrolled() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
editBook(book) {
|
|
||||||
var bookIds = this.books.map((e) => e.id)
|
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
|
||||||
this.$store.commit('showEditModal', book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.outer-container {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
overflow: visible;
|
|
||||||
height: calc(100% - 50px);
|
|
||||||
width: calc(100% - 10px);
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
.inner-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.table-header {
|
|
||||||
float: left;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.header-shadow {
|
|
||||||
box-shadow: 3px 8px 3px #11111155;
|
|
||||||
}
|
|
||||||
.table-body {
|
|
||||||
float: left;
|
|
||||||
height: 100%;
|
|
||||||
width: inherit;
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding-right: 0px;
|
|
||||||
}
|
|
||||||
.header-cell {
|
|
||||||
background-color: #22222288;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-align: left;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: semi-bold;
|
|
||||||
}
|
|
||||||
.body-cell {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.book-row {
|
|
||||||
background-color: #22222288;
|
|
||||||
}
|
|
||||||
.book-row:nth-child(odd) {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
.book-row.selected {
|
|
||||||
background-color: rgba(0, 255, 0, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
|
||||||
<td class="body-cell min-w-12 max-w-12">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
|
||||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-6 max-w-6">
|
|
||||||
<covers-hover-book-cover :audiobook="book" />
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
|
||||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
|
||||||
<p class="truncate">
|
|
||||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
|
||||||
</p>
|
|
||||||
</nuxt-link>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ seriesText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
|
||||||
<p class="truncate">{{ book.book.description }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.narrator }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ genresText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ tagsText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<div class="flex">
|
|
||||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
|
||||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
|
||||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
book: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
userAudiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isProcessingReadUpdate: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
audiobookId() {
|
|
||||||
return this.book.id
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
processingBatch() {
|
|
||||||
return this.$store.state.processingBatch
|
|
||||||
},
|
|
||||||
bookObj() {
|
|
||||||
return this.book.book || {}
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.bookObj.series || null
|
|
||||||
},
|
|
||||||
volumeNumber() {
|
|
||||||
return this.bookObj.volumeNumber || null
|
|
||||||
},
|
|
||||||
seriesText() {
|
|
||||||
if (!this.series) return ''
|
|
||||||
if (!this.volumeNumber) return this.series
|
|
||||||
return `${this.series} #${this.volumeNumber}`
|
|
||||||
},
|
|
||||||
genresText() {
|
|
||||||
if (!this.bookObj.genres) return ''
|
|
||||||
return this.bookObj.genres.join(', ')
|
|
||||||
},
|
|
||||||
tagsText() {
|
|
||||||
return (this.book.tags || []).join(', ')
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.book.isMissing
|
|
||||||
},
|
|
||||||
isInvalid() {
|
|
||||||
return this.book.isInvalid
|
|
||||||
},
|
|
||||||
numEbooks() {
|
|
||||||
return this.book.numEbooks
|
|
||||||
},
|
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
|
||||||
},
|
|
||||||
showReadButton() {
|
|
||||||
return this.showExperimentalFeatures && this.numEbooks
|
|
||||||
},
|
|
||||||
showPlayButton() {
|
|
||||||
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
|
||||||
},
|
|
||||||
userIsRead() {
|
|
||||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
selectBtnClick() {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
|
||||||
},
|
|
||||||
openEbook() {
|
|
||||||
this.$store.commit('showEReader', this.book)
|
|
||||||
},
|
|
||||||
downloadClick() {
|
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
|
||||||
},
|
|
||||||
toggleRead() {
|
|
||||||
var updatePayload = {
|
|
||||||
isRead: !this.userIsRead
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
startStream() {
|
|
||||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
|
||||||
},
|
|
||||||
editClick() {
|
|
||||||
this.$emit('edit', this.book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,22 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && isRootUser" 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">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
<div class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full flex flex-col items-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 py-4">No results for query</p>
|
||||||
|
</div>
|
||||||
|
<!-- Alternate plain view -->
|
||||||
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-item-slider>
|
||||||
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</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' }">{{ $strings[shelf.labelStringKey] }}</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' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-authors-slider>
|
||||||
|
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-narrators-slider>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Regular bookshelf view -->
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<template v-for="(shelf, index) in shelves">
|
||||||
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,41 +57,104 @@ export default {
|
|||||||
keywordFilterTimeout: null,
|
keywordFilterTimeout: null,
|
||||||
scannerParseSubtitle: false,
|
scannerParseSubtitle: false,
|
||||||
wrapperClientWidth: 0,
|
wrapperClientWidth: 0,
|
||||||
shelves: []
|
shelves: [],
|
||||||
|
lastItemIndexSelected: -1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
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
|
||||||
},
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
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')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
return coverSize
|
return coverSize
|
||||||
},
|
},
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
isCoverSquareAspectRatio() {
|
isCoverSquareAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio == 1
|
||||||
},
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
return this.bookCoverWidth / baseSize
|
return this.bookCoverWidth / baseSize
|
||||||
|
},
|
||||||
|
selectedMediaItems() {
|
||||||
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
selectEntity({ entity, shiftKey }, shelfIndex) {
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
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
|
||||||
@@ -84,8 +167,8 @@ export default {
|
|||||||
this.loaded = true
|
this.loaded = true
|
||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
var categories = await this.$axios
|
const categories = await this.$axios
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -93,147 +176,259 @@ 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.audiobooks) {
|
if (this.results.books?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'audiobooks',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
type: 'books',
|
labelStringKey: 'LabelBooks',
|
||||||
entities: this.results.audiobooks.map((ab) => ab.audiobook)
|
type: 'book',
|
||||||
|
entities: this.results.books.map((res) => res.libraryItem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series) {
|
if (this.results.podcasts?.length) {
|
||||||
|
shelves.push({
|
||||||
|
id: 'podcasts',
|
||||||
|
label: 'Podcasts',
|
||||||
|
labelStringKey: 'LabelPodcasts',
|
||||||
|
type: 'podcast',
|
||||||
|
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
name: seriesObj.series,
|
...seriesObj.series,
|
||||||
books: seriesObj.audiobooks,
|
books: seriesObj.books,
|
||||||
type: 'series'
|
type: 'series'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags) {
|
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 {
|
||||||
name: tagObj.tag,
|
name: tagObj.name,
|
||||||
books: tagObj.audiobooks,
|
books: tagObj.books || [],
|
||||||
type: 'tags'
|
type: 'tags'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors) {
|
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 {
|
||||||
id: a.author,
|
...a,
|
||||||
name: a.author,
|
|
||||||
numBooks: a.numBooks,
|
|
||||||
type: 'author'
|
type: 'author'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
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.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
userUpdated(user) {
|
||||||
console.log('Audiobook added', audiobook)
|
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||||
// TODO: Check if audiobook would be on this shelf
|
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) {
|
||||||
|
console.log('libraryItem added', libraryItem)
|
||||||
|
// TODO: Check if libraryItem would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
console.log('libraryItem updated', libraryItem)
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.map((ent) => {
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
if (ent.id === audiobook.id) {
|
if (ent.id === libraryItem.id) {
|
||||||
return audiobook
|
return libraryItem
|
||||||
}
|
}
|
||||||
return ent
|
return ent
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.map((book) => {
|
ent.books = ent.books.map((book) => {
|
||||||
if (book.id === audiobook.id) return audiobook
|
if (book.id === libraryItem.id) return libraryItem
|
||||||
return book
|
return book
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeBookFromShelf(audiobook) {
|
removeBookFromShelf(libraryItem) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.filter((ent) => {
|
shelf.entities = shelf.entities.filter((ent) => {
|
||||||
return ent.id !== audiobook.id
|
return ent.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.filter((book) => {
|
ent.books = ent.books.filter((book) => {
|
||||||
return book.id !== audiobook.id
|
return book.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
libraryItemRemoved(libraryItem) {
|
||||||
this.removeBookFromShelf(audiobook)
|
this.removeBookFromShelf(libraryItem)
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('libraryItems added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
|
||||||
if (!this.search) {
|
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||||
|
if (!this.search && isThisLibrary) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(items) {
|
||||||
audiobooks.forEach((ab) => {
|
items.forEach((li) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(li)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
episodeAdded(episodeWithLibraryItem) {
|
||||||
|
console.log('Podcast episode added', 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) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.id === author.id) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
...author
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
authorRemoved(author) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('user_updated', this.userUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||||
} 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('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('user_updated', this.userUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'books'" 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="selectBook" @edit="editBook" />
|
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
|
<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"
|
||||||
|
:continue-listening-shelf="continueListeningShelf"
|
||||||
|
class="relative mx-2"
|
||||||
|
@hook:updated="updatedBookCard"
|
||||||
|
@select="selectItem"
|
||||||
|
@editPodcast="editItem"
|
||||||
|
@edit="editEpisode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
@@ -14,24 +33,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
|
||||||
</nuxt-link>
|
|
||||||
</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">
|
||||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
|
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
</template>
|
||||||
</nuxt-link>
|
</div>
|
||||||
|
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||||
|
<template v-for="entity in shelf.entities">
|
||||||
|
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
|
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||||
<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">
|
||||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,7 +76,8 @@ export default {
|
|||||||
},
|
},
|
||||||
sizeMultiplier: Number,
|
sizeMultiplier: Number,
|
||||||
bookCoverWidth: Number,
|
bookCoverWidth: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -67,21 +88,14 @@ export default {
|
|||||||
updateTimer: null
|
updateTimer: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
isSelectionMode(newVal) {
|
|
||||||
this.updateSelectionMode(newVal)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverHeight() {
|
bookCoverHeight() {
|
||||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
|
if (this.shelf.type === 'narrators') return 148
|
||||||
return this.bookCoverHeight + 48
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
|
||||||
},
|
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
if (window.innerWidth < 768) return 1
|
if (window.innerWidth < 768) return 1
|
||||||
return 2.5
|
return 2.5
|
||||||
@@ -90,29 +104,51 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
editBook(audiobook) {
|
clearSelectedEntities() {
|
||||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
this.updateSelectionMode(false)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
},
|
||||||
this.$store.commit('showEditModal', audiobook)
|
editAuthor(author) {
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
|
},
|
||||||
|
editItem(libraryItem) {
|
||||||
|
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
},
|
||||||
|
editEpisode({ libraryItem, episode }) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
updateSelectionMode(val) {
|
updateSelectionMode(val) {
|
||||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||||
if (this.shelf.type === 'books') {
|
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 = selectedAudiobooks.includes(ent.id)
|
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||||
|
})
|
||||||
|
} else if (this.shelf.type === 'episode') {
|
||||||
|
this.shelf.entities.forEach((ent) => {
|
||||||
|
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||||
|
if (!component || !component.length) return
|
||||||
|
component = component[0]
|
||||||
|
component.setSelectionMode(val)
|
||||||
|
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectBook(audiobook) {
|
selectItem(payload) {
|
||||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
this.$emit('selectEntity', payload)
|
||||||
|
},
|
||||||
|
itemSelectedEvt() {
|
||||||
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
},
|
},
|
||||||
scrolled() {
|
scrolled() {
|
||||||
clearTimeout(this.scrollTimer)
|
clearTimeout(this.scrollTimer)
|
||||||
@@ -156,6 +192,14 @@ export default {
|
|||||||
this.canScrollLeft = false
|
this.canScrollLeft = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -163,25 +207,13 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.categorizedBookshelfRow {
|
.categorizedBookshelfRow {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
width: calc(100vw - 80px);
|
|
||||||
|
|
||||||
/* background-color: rgb(214, 116, 36); */
|
|
||||||
background-image: var(--bookshelf-texture-img);
|
background-image: var(--bookshelf-texture-img);
|
||||||
/* background-position: center; */
|
|
||||||
/* background-size: contain; */
|
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
|
||||||
.categorizedBookshelfRow {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookshelfDividerCategorized {
|
.bookshelfDividerCategorized {
|
||||||
background: rgb(149, 119, 90);
|
background: rgb(149, 119, 90);
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,100 @@
|
|||||||
<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>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>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 :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>Series</p>
|
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||||
|
</nuxt-link>
|
||||||
|
<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 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="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-icons-outlined text-lg">collections_bookmark</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 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">{{ $strings.ButtonSearch }}</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' && !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">
|
<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>
|
||||||
{{ selectedSeries }}
|
|
||||||
</p>
|
|
||||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
|
||||||
<span class="font-mono">{{ numShowing }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
|
||||||
|
|
||||||
|
<!-- 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" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
<!-- collapse series checkbox -->
|
||||||
<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" />
|
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||||
<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">
|
<!-- library filter select -->
|
||||||
<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')">
|
<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" />
|
||||||
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
|
|
||||||
</div>
|
<!-- library sort select -->
|
||||||
<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')">
|
<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" />
|
||||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
|
||||||
</div>
|
<!-- series filter select -->
|
||||||
</div> -->
|
<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" />
|
||||||
</template>
|
</template>
|
||||||
|
<!-- authors page -->
|
||||||
|
<template v-else-if="page === 'authors'">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,57 +104,331 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
page: String,
|
page: String,
|
||||||
isHome: Boolean,
|
isHome: Boolean,
|
||||||
selectedSeries: String,
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String
|
authors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
settings: {},
|
settings: {},
|
||||||
hasInit: false,
|
hasInit: false,
|
||||||
totalEntities: 0,
|
totalEntities: 0,
|
||||||
keywordFilter: null,
|
processingSeries: false,
|
||||||
keywordTimeout: null
|
processingIssues: false,
|
||||||
|
processingAuthors: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isGridMode() {
|
seriesContextMenuItems() {
|
||||||
return this.viewMode === 'grid'
|
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: 'Re-Add Series to Continue Listening',
|
||||||
|
action: 're-add-to-continue-listening'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
showSortFilters() {
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDownload() {
|
||||||
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
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.page) return 'Books'
|
if (this.isAlbumsPage) return 'Albums'
|
||||||
if (this.page === 'series') return 'Series'
|
if (this.isMusicLibrary) return 'Tracks'
|
||||||
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() {
|
seriesName() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.selectedSeries ? this.selectedSeries.name : null
|
||||||
},
|
},
|
||||||
homePage() {
|
seriesProgress() {
|
||||||
return this.$route.name === 'library-library'
|
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||||
},
|
},
|
||||||
libraryBookshelfPage() {
|
seriesRssFeed() {
|
||||||
return this.$route.name === 'library-library-bookshelf-id'
|
return this.selectedSeries ? this.selectedSeries.rssFeed : null
|
||||||
},
|
},
|
||||||
showLibrary() {
|
seriesLibraryItemIds() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
if (!this.seriesProgress) return []
|
||||||
|
return this.seriesProgress.libraryItemIds || []
|
||||||
|
},
|
||||||
|
isBatchSelecting() {
|
||||||
|
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||||
|
},
|
||||||
|
isSeriesRemovedFromContinueListening() {
|
||||||
|
if (!this.seriesId) return false
|
||||||
|
return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)
|
||||||
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
|
isIssuesFilter() {
|
||||||
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
|
},
|
||||||
|
contextMenuItems() {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||||
|
items.push({
|
||||||
|
text: 'Export OPML',
|
||||||
|
action: 'export-opml'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
searchBackArrow() {
|
contextMenuAction({ action }) {
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
if (action === 'export-opml') {
|
||||||
|
this.exportOPML()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
seriesBackArrow() {
|
exportOPML() {
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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('Series re-added to continue listening')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to re-add series to continue listening', error)
|
||||||
|
this.$toast.error('Failed to re-add series to continue listening')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async matchAllAuthors() {
|
||||||
|
this.processingAuthors = true
|
||||||
|
|
||||||
|
for (const author of this.authors) {
|
||||||
|
const payload = {}
|
||||||
|
if (author.asin) payload.asin = author.asin
|
||||||
|
else payload.q = author.name
|
||||||
|
|
||||||
|
payload.region = 'us'
|
||||||
|
if (this.libraryProvider.startsWith('audible.')) {
|
||||||
|
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
console.error(`Author ${author.name} not found`)
|
||||||
|
this.$toast.error(`Author ${author.name} not found`)
|
||||||
|
} else if (response.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(`No updates were made for Author ${response.author.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||||
|
}
|
||||||
|
this.processingAuthors = false
|
||||||
|
},
|
||||||
|
removeAllIssues() {
|
||||||
|
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||||
|
this.processingIssues = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Removed library items with issues')
|
||||||
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
|
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove library items with issues', error)
|
||||||
|
this.$toast.error('Failed to remove library items with issues')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processingIssues = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markSeriesFinished() {
|
||||||
|
const newIsFinished = !this.isSeriesFinished
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.processingSeries = true
|
||||||
|
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||||
|
return {
|
||||||
|
libraryItemId: lid,
|
||||||
|
isFinished: newIsFinished
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
|
this.$axios
|
||||||
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
|
||||||
|
this.selectedSeries.progress.isFinished = newIsFinished
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
|
||||||
|
console.error('Failed to batch update read/not read', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
updateOrder() {
|
updateOrder() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
@@ -114,9 +436,18 @@ export default {
|
|||||||
updateFilter() {
|
updateFilter() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
updateSeriesSort() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
|
updateSeriesFilter() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
updateCollapseSeries() {
|
updateCollapseSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
updateCollapseBookSeries() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||||
},
|
},
|
||||||
@@ -131,24 +462,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>
|
||||||
@@ -158,4 +496,4 @@ export default {
|
|||||||
#toolbar {
|
#toolbar {
|
||||||
box-shadow: 0px 8px 6px #111111aa;
|
box-shadow: 0px 8px 6px #111111aa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<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">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
<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' }">
|
||||||
<p>{{ route.title }}</p>
|
<div class="flex justify-between">
|
||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
</nuxt-link>
|
|
||||||
|
|
||||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }">
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,65 +30,98 @@ export default {
|
|||||||
isOpen: Boolean
|
isOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userIsRoot() {
|
Source() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
configRoutes() {
|
configRoutes() {
|
||||||
if (!this.userIsRoot) {
|
if (!this.userIsAdminOrUp) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: this.$strings.HeaderYourStats,
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
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',
|
||||||
|
title: this.$strings.HeaderListeningSessions,
|
||||||
|
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: 'Log',
|
title: this.$strings.HeaderLogs,
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-library-stats',
|
id: 'config-notifications',
|
||||||
title: 'Library Stats',
|
title: this.$strings.HeaderNotifications,
|
||||||
path: '/config/library-stats'
|
path: '/config/notifications'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-stats',
|
id: 'config-email',
|
||||||
title: 'Your Stats',
|
title: this.$strings.HeaderEmail,
|
||||||
path: '/config/stats'
|
path: '/config/email'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-item-metadata-utils',
|
||||||
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
|
path: '/config/item-metadata-utils'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.currentLibraryId) {
|
||||||
|
configRoutes.push({
|
||||||
|
id: 'config-library-stats',
|
||||||
|
title: this.$strings.HeaderLibraryStats,
|
||||||
|
path: '/config/library-stats'
|
||||||
|
})
|
||||||
|
configRoutes.push({
|
||||||
|
id: 'config-stats',
|
||||||
|
title: this.$strings.HeaderYourStats,
|
||||||
|
path: '/config/stats'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return configRoutes
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
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(' ')
|
||||||
},
|
},
|
||||||
@@ -90,9 +131,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
|
||||||
@@ -109,11 +152,17 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
currentVersionChangelog() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickChangelog() {
|
||||||
|
this.showChangelogModal = true
|
||||||
|
},
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
if (!this.isOpen) return
|
if (!this.isOpen) return
|
||||||
this.closeDrawer()
|
this.closeDrawer()
|
||||||
|
|||||||
@@ -6,27 +6,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="initialized && !totalShelves && !hasFilter && entityName === '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">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @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>
|
||||||
<div class="flex justify-center mt-2">
|
<!-- Clear filter only available on Library bookshelf -->
|
||||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
||||||
|
<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 bottom-4 right-4 z-50" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
|
||||||
<p class="text-sm py-0.5">Texture</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -41,6 +36,7 @@ export default {
|
|||||||
mixins: [bookshelfCardsHelpers],
|
mixins: [bookshelfCardsHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
routeFullPath: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
bookshelfHeight: 0,
|
bookshelfHeight: 0,
|
||||||
bookshelfWidth: 0,
|
bookshelfWidth: 0,
|
||||||
@@ -60,13 +56,13 @@ export default {
|
|||||||
totalShelves: 0,
|
totalShelves: 0,
|
||||||
bookshelfMarginLeft: 0,
|
bookshelfMarginLeft: 0,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
isSelectAll: false,
|
|
||||||
currentSFQueryString: null,
|
currentSFQueryString: null,
|
||||||
pendingReset: false,
|
pendingReset: false,
|
||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
currScrollTop: 0,
|
currScrollTop: 0,
|
||||||
resizeTimeout: null,
|
resizeTimeout: null,
|
||||||
mountWindowWidth: 0
|
mountWindowWidth: 0,
|
||||||
|
lastItemIndexSelected: -1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -79,22 +75,39 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
libraryMediaType() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
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.hasFilter) return `No Results for filter "${this.filterValue}"`
|
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
|
||||||
return 'No results'
|
if (this.hasFilter) {
|
||||||
|
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
||||||
|
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
||||||
|
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
|
||||||
|
}
|
||||||
|
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')
|
||||||
},
|
},
|
||||||
@@ -107,24 +120,23 @@ export default {
|
|||||||
collapseSeries() {
|
collapseSeries() {
|
||||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||||
},
|
},
|
||||||
coverAspectRatio() {
|
collapseBookSeries() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
|
||||||
},
|
},
|
||||||
bookshelfView() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
sortingIgnorePrefix() {
|
sortingIgnorePrefix() {
|
||||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||||
},
|
},
|
||||||
isCoverSquareAspectRatio() {
|
isCoverSquareAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio == 1
|
||||||
|
},
|
||||||
|
bookshelfView() {
|
||||||
|
return this.$store.getters['getBookshelfView']
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
return this.bookshelfView === this.$constants.BookshelfView.DETAIL
|
||||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
|
||||||
},
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
|
||||||
},
|
},
|
||||||
hasFilter() {
|
hasFilter() {
|
||||||
return this.filterBy && this.filterBy !== 'all'
|
return this.filterBy && this.filterBy !== 'all'
|
||||||
@@ -143,16 +155,16 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isEntityBook() {
|
libraryName() {
|
||||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||||
return coverSize
|
return coverSize
|
||||||
},
|
},
|
||||||
bookHeight() {
|
bookHeight() {
|
||||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||||
return this.bookWidth * 1.6
|
return this.bookWidth * 1.6
|
||||||
},
|
},
|
||||||
shelfPadding() {
|
shelfPadding() {
|
||||||
@@ -176,47 +188,106 @@ export default {
|
|||||||
return 6
|
return 6
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
if (this.isAlternativeBookshelfView) {
|
||||||
|
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
|
||||||
|
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
|
||||||
|
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||||
|
}
|
||||||
return this.entityHeight + 40
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
return this.entityWidth / baseSize
|
return this.entityWidth / baseSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
|
||||||
},
|
|
||||||
clearFilter() {
|
clearFilter() {
|
||||||
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
|
||||||
this.isSelectAll = 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('toggleAudiobookSelected', 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.selectedAudiobooks.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 +300,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 +313,12 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
|
const 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`
|
||||||
|
|
||||||
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 +334,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) {
|
||||||
@@ -300,11 +375,11 @@ export default {
|
|||||||
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
||||||
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
||||||
if (!this.pagesLoaded[firstBookPage]) {
|
if (!this.pagesLoaded[firstBookPage]) {
|
||||||
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||||
this.loadPage(firstBookPage)
|
this.loadPage(firstBookPage)
|
||||||
}
|
}
|
||||||
if (!this.pagesLoaded[lastBookPage]) {
|
if (!this.pagesLoaded[lastBookPage]) {
|
||||||
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||||
this.loadPage(lastBookPage)
|
this.loadPage(lastBookPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +407,6 @@ export default {
|
|||||||
this.totalEntities = 0
|
this.totalEntities = 0
|
||||||
this.currentPage = 0
|
this.currentPage = 0
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
this.isSelectAll = false
|
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
@@ -368,15 +442,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('filter', `series.${this.seriesId}`)
|
searchParams.set('sort', this.seriesSortBy)
|
||||||
searchParams.set('sort', 'book.volumeNumber')
|
searchParams.set('desc', this.seriesSortDesc ? 1 : 0)
|
||||||
searchParams.set('desc', 0)
|
searchParams.set('filter', this.seriesFilterBy)
|
||||||
|
} else if (this.page === 'series-books') {
|
||||||
|
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)
|
||||||
@@ -385,15 +464,13 @@ export default {
|
|||||||
searchParams.set('sort', this.orderBy)
|
searchParams.set('sort', this.orderBy)
|
||||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||||
}
|
}
|
||||||
if (this.collapseSeries) {
|
if (this.collapseSeries && !this.isPodcast) {
|
||||||
searchParams.set('collapseseries', 1)
|
searchParams.set('collapseseries', 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -404,13 +481,21 @@ export default {
|
|||||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||||
window.history.replaceState({ path: newurl }, '', newurl)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
seriesSortUpdated() {
|
||||||
var wasUpdated = this.checkUpdateSearchParams()
|
var wasUpdated = this.checkUpdateSearchParams()
|
||||||
|
if (wasUpdated) {
|
||||||
|
this.resetEntities()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settingsUpdated(settings) {
|
||||||
|
const wasUpdated = this.checkUpdateSearchParams()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||||
@@ -420,49 +505,100 @@ 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)
|
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('Audiobook added', audiobook)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
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 === audiobook.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities[indexOf] = audiobook
|
this.entities[indexOf] = libraryItem
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
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 === audiobook.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 !== audiobook.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.remountEntities()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('items added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(libraryItems) {
|
||||||
audiobooks.forEach((ab) => {
|
libraryItems.forEach((ab) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(ab)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
collectionAdded(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||||
|
this.resetEntities()
|
||||||
|
},
|
||||||
|
collectionUpdated(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities[indexOf] = collection
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
this.entityComponentRefs[indexOf].setEntity(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
collectionRemoved(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||||
|
this.totalEntities--
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
initSizeData(_bookshelf) {
|
initSizeData(_bookshelf) {
|
||||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||||
if (!bookshelf) {
|
if (!bookshelf) {
|
||||||
@@ -494,6 +630,15 @@ export default {
|
|||||||
await this.fetchEntites(0)
|
await this.fetchEntites(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntites(0, lastBookIndex)
|
this.mountEntites(0, lastBookIndex)
|
||||||
|
|
||||||
|
// Set last scroll position for this bookshelf page
|
||||||
|
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||||
|
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||||
|
if (path === this.routeFullPath) {
|
||||||
|
// Exact path match with query so use scroll position
|
||||||
|
window.bookshelf.scrollTop = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
executeRebuild() {
|
executeRebuild() {
|
||||||
clearTimeout(this.resizeTimeout)
|
clearTimeout(this.resizeTimeout)
|
||||||
@@ -519,17 +664,22 @@ 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('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||||
|
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)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -540,17 +690,23 @@ 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('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||||
|
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)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -563,13 +719,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', this.currentLibraryId)
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
||||||
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
||||||
@@ -580,6 +748,11 @@ export default {
|
|||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
this.removeListeners()
|
this.removeListeners()
|
||||||
|
|
||||||
|
// Set bookshelf scroll position for specific bookshelf page and query
|
||||||
|
if (window.bookshelf) {
|
||||||
|
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -588,6 +761,7 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
|
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
|
||||||
|
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
|
||||||
|
</div>
|
||||||
|
</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,
|
||||||
|
showAddButton: Boolean
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clicked() {
|
||||||
|
this.$emit('clicked')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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,45 +1,63 @@
|
|||||||
<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="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
</svg>
|
</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 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'">
|
||||||
|
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||||
|
|
||||||
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<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="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" />
|
<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">Library</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||||
|
|
||||||
<div v-show="showLibrary" 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 :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 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'">
|
||||||
<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="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>
|
</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.ButtonSeries }}</p>
|
||||||
|
|
||||||
<div v-show="isSeriesPage" 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 :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/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'">
|
||||||
<span class="material-icons-outlined">collections_bookmark</span>
|
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
|
||||||
|
|
||||||
<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.ButtonCollections }}</p>
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" 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="showExperimentalFeatures" :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="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'">
|
||||||
|
<span class="material-icons text-2.5xl">queue_music</span>
|
||||||
|
|
||||||
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<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'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -47,15 +65,47 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Authors</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||||
|
|
||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="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-icons text-2xl">record_voice_over</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="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.ButtonSearch }}</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-icons-outlined 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-icons text-2xl">file_download</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'">
|
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||||
<span class="material-icons text-2xl">warning</span>
|
<span class="material-icons text-2xl">warning</span>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
|
<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 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">
|
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||||
@@ -63,46 +113,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<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' }">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
</svg>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
Source() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
isMobileLandscape() {
|
||||||
|
return this.$store.state.globals.isMobileLandscape
|
||||||
|
},
|
||||||
|
isShowingBookshelfToolbar() {
|
||||||
|
if (!this.$route.name) return false
|
||||||
|
return this.$route.name.startsWith('library')
|
||||||
|
},
|
||||||
|
offsetTop() {
|
||||||
|
return 64
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
paramId() {
|
paramId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.$route.params ? this.$route.params.id || '' : ''
|
||||||
@@ -110,6 +153,30 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isBookLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'book'
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusicLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'music'
|
||||||
|
},
|
||||||
|
isPodcastDownloadQueuePage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-download-queue'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
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'
|
||||||
},
|
},
|
||||||
@@ -119,21 +186,52 @@ 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'
|
||||||
|
},
|
||||||
libraryBookshelfPage() {
|
libraryBookshelfPage() {
|
||||||
return this.$route.name === 'library-library-bookshelf-id'
|
return this.$route.name === 'library-library-bookshelf-id'
|
||||||
},
|
},
|
||||||
showLibrary() {
|
showLibrary() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||||
},
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
showingIssues() {
|
showingIssues() {
|
||||||
if (!this.$route.query) return false
|
if (!this.$route.query) return false
|
||||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||||
},
|
},
|
||||||
numIssues() {
|
numIssues() {
|
||||||
return this.$store.state.libraries.issues || 0
|
return this.$store.state.libraries.issues || 0
|
||||||
|
},
|
||||||
|
versionData() {
|
||||||
|
return this.$store.state.versionData || {}
|
||||||
|
},
|
||||||
|
hasUpdate() {
|
||||||
|
return !!this.versionData.hasUpdate
|
||||||
|
},
|
||||||
|
githubTagUrl() {
|
||||||
|
return this.versionData.githubTagUrl
|
||||||
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
showPlaylists() {
|
||||||
|
return this.$store.state.libraries.numUserPlaylists > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickChangelog() {
|
||||||
|
this.showChangelogModal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamAudiobook" 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="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<div id="videoDock" />
|
||||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||||
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
<div class="flex items-start mb-6 md: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">
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="text-gray-400 flex items-center">
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
|
<div class="flex items-center">
|
||||||
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link>
|
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||||
</p>
|
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||||
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||||
|
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||||
|
</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-icons text-xs">schedule</span>
|
||||||
<p class="font-mono text-sm pl-2 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 px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<player-ui
|
||||||
<audio-player
|
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
:paused="!isPlaying"
|
:paused="!isPlaying"
|
||||||
@@ -33,6 +40,7 @@
|
|||||||
:bookmarks="bookmarks"
|
:bookmarks="bookmarks"
|
||||||
:sleep-timer-set="sleepTimerSet"
|
:sleep-timer-set="sleepTimerSet"
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
|
:is-podcast="isPodcast"
|
||||||
@playPause="playPause"
|
@playPause="playPause"
|
||||||
@jumpForward="jumpForward"
|
@jumpForward="jumpForward"
|
||||||
@jumpBackward="jumpBackward"
|
@jumpBackward="jumpBackward"
|
||||||
@@ -42,11 +50,14 @@
|
|||||||
@close="closePlayer"
|
@close="closePlayer"
|
||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @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-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -60,84 +71,141 @@ export default {
|
|||||||
totalDuration: 0,
|
totalDuration: 0,
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
bookmarkCurrentTime: 0,
|
bookmarkCurrentTime: 0,
|
||||||
bookmarkAudiobookId: null,
|
|
||||||
playerLoading: false,
|
playerLoading: false,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
|
showPlayerQueueItemsModal: false,
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerTime: 0,
|
sleepTimerTime: 0,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null
|
sleepTimer: null,
|
||||||
|
displayTitle: null,
|
||||||
|
currentPlaybackRate: 1,
|
||||||
|
syncFailedToast: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
isSquareCover() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
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.bookCoverAspectRatio === 1) return -10
|
|
||||||
return -64
|
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
if (this.media.coverPath) return this.media.coverPath
|
||||||
return 'Logo.png'
|
return 'Logo.png'
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
userAudiobook() {
|
userMediaProgress() {
|
||||||
if (!this.audiobookId) return
|
if (!this.libraryItemId) return
|
||||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userAudiobookCurrentTime() {
|
userItemCurrentTime() {
|
||||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
|
||||||
},
|
},
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.userAudiobook) return []
|
if (!this.libraryItemId) return []
|
||||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
audiobookId() {
|
streamEpisode() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
if (!this.$store.state.streamEpisodeId) return null
|
||||||
|
const episodes = this.streamLibraryItem.media.episodes || []
|
||||||
|
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||||
},
|
},
|
||||||
book() {
|
libraryItemId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
return this.streamLibraryItem?.id || null
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.streamLibraryItem?.media || {}
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.streamLibraryItem?.mediaType === 'music'
|
||||||
|
},
|
||||||
|
isExplicit() {
|
||||||
|
return this.mediaMetadata.explicit || false
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||||
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||||
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
author() {
|
authors() {
|
||||||
return this.book.author || 'Unknown'
|
return this.mediaMetadata.authors || []
|
||||||
},
|
|
||||||
authorFL() {
|
|
||||||
return this.book.authorFL
|
|
||||||
},
|
|
||||||
authorsList() {
|
|
||||||
return this.authorFL ? this.authorFL.split(', ') : []
|
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.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() {
|
||||||
|
if (!this.isPodcast) return null
|
||||||
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
|
},
|
||||||
|
musicArtists() {
|
||||||
|
if (!this.isMusic) return null
|
||||||
|
return this.mediaMetadata.artists.join(', ')
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
this.isPlaying = isPlaying
|
||||||
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
|
this.updateMediaSessionPlaybackState()
|
||||||
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
this.sleepTimerTime = seconds
|
this.sleepTimerTime = seconds
|
||||||
@@ -194,11 +262,16 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(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) {
|
||||||
@@ -217,7 +290,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showBookmarks() {
|
showBookmarks() {
|
||||||
this.bookmarkAudiobookId = this.audiobookId
|
|
||||||
this.bookmarkCurrentTime = this.currentTime
|
this.bookmarkCurrentTime = this.currentTime
|
||||||
this.showBookmarksModal = true
|
this.showBookmarksModal = true
|
||||||
},
|
},
|
||||||
@@ -227,31 +299,115 @@ export default {
|
|||||||
},
|
},
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
this.$store.commit('setStreamAudiobook', null)
|
this.$store.commit('setMediaPlaying', null)
|
||||||
},
|
},
|
||||||
streamProgress(data) {
|
mediaSessionPlay() {
|
||||||
if (!data.numSegments) return
|
console.log('Media session play')
|
||||||
var chunks = data.chunks
|
this.playerHandler.play()
|
||||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
},
|
||||||
if (this.$refs.audioPlayer) {
|
mediaSessionPause() {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
console.log('Media session pause')
|
||||||
} else {
|
this.playerHandler.pause()
|
||||||
console.error('No Audio Ref')
|
},
|
||||||
|
mediaSessionStop() {
|
||||||
|
console.log('Media session stop')
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
mediaSessionSeekBackward() {
|
||||||
|
console.log('Media session seek backward')
|
||||||
|
this.playerHandler.jumpBackward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekForward() {
|
||||||
|
console.log('Media session seek forward')
|
||||||
|
this.playerHandler.jumpForward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekTo(e) {
|
||||||
|
console.log('Media session seek to', e)
|
||||||
|
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||||
|
this.playerHandler.seek(e.seekTime)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
mediaSessionPreviousTrack() {
|
||||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
if (this.$refs.audioPlayer) {
|
||||||
this.playerHandler.prepareStream(stream)
|
this.$refs.audioPlayer.prevChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaSessionNextTrack() {
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.nextChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateMediaSessionPlaybackState() {
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setMediaSession() {
|
||||||
|
if (!this.streamLibraryItem) {
|
||||||
|
console.error('setMediaSession: No library item set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||||
|
const artwork = [
|
||||||
|
{
|
||||||
|
src: coverImageSrc
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: this.title,
|
||||||
|
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||||
|
album: this.mediaMetadata.seriesName || '',
|
||||||
|
artwork
|
||||||
|
})
|
||||||
|
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||||
|
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||||
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||||
|
} else {
|
||||||
|
console.warn('Media session not available')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamProgress(data) {
|
||||||
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||||
|
if (!data.numSegments) return
|
||||||
|
var chunks = data.chunks
|
||||||
|
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||||
|
if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
|
} else {
|
||||||
|
console.error('No Audio Ref')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sessionOpen(session) {
|
||||||
|
// For opening session on init (temporarily unused)
|
||||||
|
this.$store.commit('setMediaPlaying', {
|
||||||
|
libraryItem: session.libraryItem,
|
||||||
|
episodeId: session.episodeId
|
||||||
|
})
|
||||||
|
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||||
|
},
|
||||||
|
streamOpen(session) {
|
||||||
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
console.log(`[STREAM-CONTAINER] Stream Ready`)
|
console.log(`[StreamContainer] Stream Ready`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setStreamReady()
|
this.$refs.audioPlayer.setStreamReady()
|
||||||
} else {
|
} else {
|
||||||
@@ -260,7 +416,7 @@ export default {
|
|||||||
},
|
},
|
||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && 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('[StreamContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
@@ -269,32 +425,72 @@ export default {
|
|||||||
this.playerHandler.resetStream(startTime, streamId)
|
this.playerHandler.resetStream(startTime, streamId)
|
||||||
},
|
},
|
||||||
castSessionActive(isActive) {
|
castSessionActive(isActive) {
|
||||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
if (isActive && this.playerHandler.isPlayingLocalItem) {
|
||||||
// Cast session started switch to cast player
|
// Cast session started switch to cast player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
|
||||||
// Cast session ended switch to local player
|
// Cast session ended switch to local player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async playAudiobook(audiobookId) {
|
async playLibraryItem(payload) {
|
||||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
const libraryItemId = payload.libraryItemId
|
||||||
console.error('Failed to fetch full audiobook', error)
|
const episodeId = payload.episodeId || null
|
||||||
|
|
||||||
|
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||||
|
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||||
|
this.seek(payload.startTime)
|
||||||
|
} else {
|
||||||
|
this.playerHandler.play()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed to fetch full item', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!audiobook) return
|
if (!libraryItem) return
|
||||||
this.$store.commit('setStreamAudiobook', audiobook)
|
|
||||||
|
|
||||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
this.$store.commit('setMediaPlaying', {
|
||||||
|
libraryItem,
|
||||||
|
episodeId,
|
||||||
|
queueItems: payload.queueItems || []
|
||||||
|
})
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||||
|
},
|
||||||
|
pauseItem() {
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
showFailedProgressSyncs() {
|
||||||
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
|
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('play-audiobook', this.playAudiobook)
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
|
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('play-audiobook', this.playAudiobook)
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -303,4 +499,4 @@ export default {
|
|||||||
#streamContainer {
|
#streamContainer {
|
||||||
box-shadow: 0px -6px 8px #1111113f;
|
box-shadow: 0px -6px 8px #1111113f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
|
||||||
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
|
||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
search: String,
|
|
||||||
matchKey: String,
|
|
||||||
matchText: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
|
||||||
return 50
|
|
||||||
},
|
|
||||||
book() {
|
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.book ? this.book.title : 'No Title'
|
|
||||||
},
|
|
||||||
subtitle() {
|
|
||||||
return this.book ? this.book.subtitle : ''
|
|
||||||
},
|
|
||||||
authorFL() {
|
|
||||||
return this.book ? this.book.authorFL : 'Unknown'
|
|
||||||
},
|
|
||||||
matchHtml() {
|
|
||||||
if (!this.matchText || !this.search) return ''
|
|
||||||
if (this.matchKey === 'subtitle') return ''
|
|
||||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
|
||||||
if (matchSplit.length < 2) return ''
|
|
||||||
|
|
||||||
var html = ''
|
|
||||||
var totalLenSoFar = 0
|
|
||||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
|
||||||
var indexOf = matchSplit[i].length
|
|
||||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
|
||||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
|
||||||
totalLenSoFar += indexOf + this.search.length
|
|
||||||
|
|
||||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
|
||||||
}
|
|
||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
|
||||||
html += lastPart
|
|
||||||
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
|
||||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
|
||||||
if (this.matchKey === '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: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.audiobookSearchCardContent {
|
|
||||||
width: calc(100% - 80px);
|
|
||||||
height: 75px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,28 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<!-- Image or placeholder -->
|
||||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
<covers-author-image :author="author" />
|
||||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
|
||||||
<path
|
|
||||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
|
<!-- Author name & num books overlay -->
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
|
<span class="material-icons text-lg">search</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<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 :text="$strings.LabelEdit" direction="bottom">
|
||||||
|
<span class="material-icons text-lg">edit</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<widgets-loading-spinner size="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||||
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -34,11 +44,16 @@ export default {
|
|||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
sizeMultiplier: Number
|
sizeMultiplier: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
nameBelow: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
placeholder: '/Logo.png'
|
searching: false,
|
||||||
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -48,30 +63,69 @@ export default {
|
|||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
name() {
|
name() {
|
||||||
return this._author.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
image() {
|
asin() {
|
||||||
return this._author.image || null
|
return this._author.asin || ''
|
||||||
},
|
|
||||||
description() {
|
|
||||||
return this._author.description
|
|
||||||
},
|
|
||||||
lastUpdate() {
|
|
||||||
return this._author.lastUpdate
|
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
},
|
||||||
imgSrc() {
|
userCanUpdate() {
|
||||||
if (!this.image) return this.placeholder
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
|
},
|
||||||
|
currentLibraryId() {
|
||||||
var url = new URL(encodedImg, document.baseURI)
|
return this.$store.state.libraries.currentLibraryId
|
||||||
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
mounted() {}
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
this.searching = true
|
||||||
|
const payload = {}
|
||||||
|
if (this.asin) payload.asin = this.asin
|
||||||
|
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) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error(`Author ${this.name} not found`)
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||||
|
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||||
|
} else {
|
||||||
|
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||||
|
}
|
||||||
|
this.searching = false
|
||||||
|
},
|
||||||
|
setSearching(isSearching) {
|
||||||
|
this.searching = isSearching
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ author }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,12 +12,19 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
author: String
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
name() {
|
||||||
|
return this.author.name
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b border-gray-700 pb-2">
|
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
|
||||||
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||||
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
|
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||||
<div class="px-4 flex-grow">
|
<div class="w-full bg-primary">
|
||||||
<div class="flex items-center">
|
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||||
<h1>{{ book.title }}</h1>
|
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
||||||
<div class="flex-grow" />
|
</div>
|
||||||
<p>{{ book.publishYear }}</p>
|
</div>
|
||||||
|
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||||
|
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||||
|
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||||
|
<div v-if="book.series && 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">
|
||||||
|
<p class="leading-3 text-xs text-gray-400">
|
||||||
|
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400">{{ book.author }}</p>
|
|
||||||
<div class="w-full max-h-12 overflow-hidden">
|
<div class="w-full max-h-12 overflow-hidden">
|
||||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="px-4 flex-grow">
|
||||||
|
<h1>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="bookCovers.length > 1" class="flex">
|
<div v-if="bookCovers.length > 1" class="flex">
|
||||||
<template v-for="cover in bookCovers">
|
<template v-for="cover in bookCovers">
|
||||||
@@ -31,6 +55,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -47,7 +72,7 @@ export default {
|
|||||||
selectMatch() {
|
selectMatch() {
|
||||||
var book = { ...this.book }
|
var book = { ...this.book }
|
||||||
book.cover = this.selectedCover
|
book.cover = this.selectedCover
|
||||||
this.$emit('select', this.book)
|
this.$emit('select', book)
|
||||||
},
|
},
|
||||||
clickCover(cover) {
|
clickCover(cover) {
|
||||||
this.selectedCover = cover
|
this.selectedCover = cover
|
||||||
@@ -57,4 +82,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>
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
|
||||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
|
||||||
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
|
||||||
|
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
|
||||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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' }">{{ collectionName }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
collection: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: Number,
|
|
||||||
default: 120
|
|
||||||
},
|
|
||||||
paddingY: {
|
|
||||||
type: Number,
|
|
||||||
default: 24
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isHovering: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labelFontSize() {
|
|
||||||
if (this.coverWidth < 160) return 0.75
|
|
||||||
return 0.875
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
_collection() {
|
|
||||||
return this.collection || {}
|
|
||||||
},
|
|
||||||
groupTo() {
|
|
||||||
return `/collection/${this._collection.id}`
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.width * 1.6
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.width / 120
|
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
bookItems() {
|
|
||||||
return this._collection.books || []
|
|
||||||
},
|
|
||||||
collectionName() {
|
|
||||||
return this._collection.name || 'No Name'
|
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleSelected() {
|
|
||||||
// Selected
|
|
||||||
},
|
|
||||||
clickEdit() {
|
|
||||||
this.$store.commit('globals/setEditCollection', this.collection)
|
|
||||||
},
|
|
||||||
mouseoverCard() {
|
|
||||||
this.isHovering = true
|
|
||||||
},
|
|
||||||
mouseleaveCard() {
|
|
||||||
this.isHovering = false
|
|
||||||
},
|
|
||||||
clickCard() {
|
|
||||||
this.$emit('click', this.collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,29 +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: width + 'px', height: height + '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="width" :height="height" :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.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">{{ bookItems.length }}</div>
|
||||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
|
|
||||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
@@ -34,11 +23,8 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
width: {
|
width: Number,
|
||||||
type: Number,
|
height: Number,
|
||||||
default: 120
|
|
||||||
},
|
|
||||||
isCategorized: Boolean,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -46,23 +32,7 @@ export default {
|
|||||||
isHovering: false
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
seriesId() {
|
|
||||||
return this.groupEncode
|
|
||||||
},
|
|
||||||
labelFontSize() {
|
|
||||||
if (this.coverWidth < 160) return 0.75
|
|
||||||
return 0.875
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
@@ -73,42 +43,15 @@ 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.groupEncode}`
|
|
||||||
} 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
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / baseSize
|
return this.width / 240
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
|
||||||
},
|
|
||||||
userProgressItems() {
|
|
||||||
return this.bookItems.map((item) => {
|
|
||||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
|
||||||
return userAudiobook || {}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
groupName() {
|
groupName() {
|
||||||
return this._group.name || 'No Name'
|
return this._group.name || 'No Name'
|
||||||
},
|
},
|
||||||
@@ -119,21 +62,16 @@ export default {
|
|||||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseoverCard() {
|
mouseoverCard() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
|
||||||
},
|
},
|
||||||
mouseleaveCard() {
|
mouseleaveCard() {
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
|
||||||
},
|
},
|
||||||
clickCard() {
|
clickCard() {
|
||||||
this.$emit('click', this.group)
|
this.$emit('click', this.group)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
|
|
||||||
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||||
|
|
||||||
|
<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' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
search: String,
|
||||||
|
matchKey: String,
|
||||||
|
matchText: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
coverWidth() {
|
||||||
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
|
return 50
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title || 'No Title'
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.mediaMetadata.subtitle || ''
|
||||||
|
},
|
||||||
|
authorName() {
|
||||||
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
|
return this.mediaMetadata.authorName || 'Unknown'
|
||||||
|
},
|
||||||
|
matchHtml() {
|
||||||
|
if (!this.matchText || !this.search) 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 === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||||
|
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||||
|
if (this.matchKey === 'subtitle') return `<p class="truncate">${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">${this.$strings.LabelSeries}: ${html}</p>`
|
||||||
|
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
||||||
|
return `${html}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.audiobookSearchCardContent {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
height: 75px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center px-1 overflow-hidden">
|
||||||
|
<div class="w-8 flex items-center justify-center">
|
||||||
|
<!-- <div class="text-lg"> -->
|
||||||
|
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||||
|
<widgets-loading-spinner v-else />
|
||||||
|
<!-- </div> -->
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
task: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.taskRunningCardContent {
|
||||||
|
width: calc(100% - 84px);
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+39
-28
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
|
||||||
</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')">
|
||||||
@@ -15,37 +15,41 @@
|
|||||||
|
|
||||||
<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="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
<ui-text-input-with-label v-model="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-model="bookData.author" :disabled="processing" label="Author" />
|
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<p class="px-1 text-sm font-semibold">{{ $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" style="height: 38px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div 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="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
|
||||||
</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>
|
<p class="px-1 text-sm font-semibold">{{ $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" style="height: 38px" />
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
<tables-uploaded-files-table :files="item.itemFiles" :title="$strings.HeaderItemFiles" class="mt-8" />
|
||||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
<tables-uploaded-files-table v-if="item.otherFiles.length" :title="$strings.HeaderOtherFiles" :files="item.otherFiles" />
|
||||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.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">{{ $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">{{ $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="isUploading" 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="$strings.MessageUploading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,15 +59,16 @@ import Path from 'path'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
book: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
mediaType: String,
|
||||||
processing: Boolean
|
processing: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
bookData: {
|
itemData: {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
author: '',
|
||||||
series: ''
|
series: ''
|
||||||
@@ -75,14 +80,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
directory() {
|
directory() {
|
||||||
if (!this.bookData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
if (this.bookData.series && this.bookData.author) {
|
if (this.isPodcast) return this.itemData.title
|
||||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
|
||||||
} else if (this.bookData.author) {
|
if (this.itemData.series && this.itemData.author) {
|
||||||
return Path.join(this.bookData.author, this.bookData.title)
|
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||||
|
} else if (this.itemData.author) {
|
||||||
|
return Path.join(this.itemData.author, this.itemData.title)
|
||||||
} else {
|
} else {
|
||||||
return this.bookData.title
|
return this.itemData.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -96,24 +106,25 @@ export default {
|
|||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.bookData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = 'Must have a title'
|
this.error = 'Must have a title'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.book.index,
|
index: this.item.index,
|
||||||
...this.bookData,
|
directory: this.directory,
|
||||||
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.book) {
|
if (this.item) {
|
||||||
this.bookData.title = this.book.title
|
this.itemData.title = this.item.title
|
||||||
this.bookData.author = this.book.author
|
this.itemData.author = this.item.author
|
||||||
this.bookData.series = this.book.series
|
this.itemData.series = this.item.series
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="card" :id="`album-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 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="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 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: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
|
<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>
|
||||||
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || ' ' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
index: Number,
|
||||||
|
width: Number,
|
||||||
|
height: Number,
|
||||||
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
albumMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
album: null,
|
||||||
|
isSelectionMode: false,
|
||||||
|
selected: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
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.875
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
|
||||||
|
return this.width / baseSize
|
||||||
|
},
|
||||||
|
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
@@ -4,21 +4,22 @@
|
|||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
<div v-show="isHovering && 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 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
|
||||||
</div> -->
|
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 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: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +29,16 @@ export default {
|
|||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
collectionMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
isTag: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +68,16 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
return this.collection ? this.collection.rssFeed : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -93,6 +113,10 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
if (this.collectionMount) {
|
||||||
|
this.setEntity(this.collectionMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="card" :id="`playlist-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 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="width" :height="height" />
|
||||||
|
</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 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 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: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
|
<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 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
index: Number,
|
||||||
|
width: Number,
|
||||||
|
height: Number,
|
||||||
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
playlistMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
playlist: null,
|
||||||
|
isSelectionMode: false,
|
||||||
|
selected: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labelFontSize() {
|
||||||
|
if (this.width < 160) return 0.75
|
||||||
|
return 0.875
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
|
||||||
|
return this.width / 120
|
||||||
|
},
|
||||||
|
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>
|
||||||
@@ -2,21 +2,28 @@
|
|||||||
<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 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 class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<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 z-0">
|
<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="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</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 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 v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
<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` }">
|
<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' }">{{ title }}</p>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
|
||||||
</div> -->
|
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 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: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ 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">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -27,11 +34,17 @@ export default {
|
|||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
isCategorized: Boolean,
|
isCategorized: Boolean,
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
sortingIgnorePrefix: Boolean,
|
||||||
|
orderBy: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -43,6 +56,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
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.875
|
||||||
@@ -51,12 +67,65 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
|
seriesId() {
|
||||||
|
return this.series ? this.series.id : ''
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series ? this.series.name : ''
|
||||||
},
|
},
|
||||||
|
nameIgnorePrefix() {
|
||||||
|
return this.series ? this.series.nameIgnorePrefix : ''
|
||||||
|
},
|
||||||
|
displayTitle() {
|
||||||
|
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||||
|
return this.title
|
||||||
|
},
|
||||||
|
displaySortLine() {
|
||||||
|
switch (this.orderBy) {
|
||||||
|
case 'addedAt':
|
||||||
|
return `${this.$strings.LabelAdded} ${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 ? this.series.books || [] : []
|
||||||
},
|
},
|
||||||
|
addedAt() {
|
||||||
|
return this.series ? this.series.addedAt : 0
|
||||||
|
},
|
||||||
|
totalDuration() {
|
||||||
|
return this.series ? this.series.totalDuration : 0
|
||||||
|
},
|
||||||
|
seriesBookProgress() {
|
||||||
|
return this.books
|
||||||
|
.map((libraryItem) => {
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||||
|
})
|
||||||
|
.filter((p) => !!p)
|
||||||
|
},
|
||||||
|
seriesBooksFinished() {
|
||||||
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
|
},
|
||||||
|
hasSeriesBookInProgress() {
|
||||||
|
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||||
|
},
|
||||||
|
seriesPercentInProgress() {
|
||||||
|
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||||
|
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||||
|
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
|
},
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
@@ -64,14 +133,18 @@ export default {
|
|||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
seriesBooksRoute() {
|
seriesBooksRoute() {
|
||||||
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
|
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
|
||||||
},
|
|
||||||
seriesId() {
|
|
||||||
return this.series ? this.$encode(this.title) : null
|
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
return this.series ? this.series.rssFeed : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||||
|
<div :style="{ width: width + 'px', height: height + 'px' }" 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-icons-outlined text-[10rem]">record_voice_over</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Narrator name & num books overlay -->
|
||||||
|
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
narrator: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
width: Number,
|
||||||
|
height: Number,
|
||||||
|
sizeMultiplier: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
name() {
|
||||||
|
return this.narrator?.name || ''
|
||||||
|
},
|
||||||
|
numBooks() {
|
||||||
|
return this.narrator?.books?.length || 0
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ narrator }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
narrator: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.narratorSearchCardContent {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<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">Fire onTest Event</ui-btn>
|
||||||
|
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</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">Test</ui-btn>
|
||||||
|
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</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('Triggered onTest Event')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
|
||||||
|
})
|
||||||
|
.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: `Trigger this notification with test data?`,
|
||||||
|
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('Triggered test notification')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
|
||||||
|
})
|
||||||
|
.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)
|
||||||
|
this.$toast.success('Notification enabled')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update notification', error)
|
||||||
|
this.$toast.error('Failed to update notification')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.enabling = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteNotificationClick() {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to delete this notification?`,
|
||||||
|
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)
|
||||||
|
this.$toast.success('Deleted notification')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to delete notification')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.deleting = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
editNotification() {
|
||||||
|
this.$emit('edit', this.notification)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-16 min-w-16">
|
||||||
|
<div class="w-full h-16 bg-primary">
|
||||||
|
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||||
|
<p class="mb-1">{{ title }}</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 truncate text-blue-200">
|
||||||
|
{{ $strings.LabelFolder }}: <span class="font-mono">{{ folderPath }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
feed: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryFolderPath: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
width: 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
return this.metadata.title || 'No Title'
|
||||||
|
},
|
||||||
|
image() {
|
||||||
|
return this.metadata.imageUrl
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.metadata.description || ''
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.metadata.author || ''
|
||||||
|
},
|
||||||
|
metadata() {
|
||||||
|
return this.feed || {}
|
||||||
|
},
|
||||||
|
numEpisodes() {
|
||||||
|
return this.feed.numEpisodes || 0
|
||||||
|
},
|
||||||
|
folderPath() {
|
||||||
|
if (!this.libraryFolderPath) return ''
|
||||||
|
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
|
||||||
|
},
|
||||||
|
detailsWidth() {
|
||||||
|
return this.width - 85
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
updated() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<!-- <div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div> -->
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
authorName: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
isProcessing: false,
|
|
||||||
provider: 'audnexus',
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Audnexus',
|
|
||||||
value: 'audnexus'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
authorName: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.searchAuthor = newVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
getSearchQuery() {
|
|
||||||
return `q=${this.searchAuthor}`
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchAuthor) {
|
|
||||||
this.$toast.warning('Author name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (result) {
|
|
||||||
this.$emit('match', result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ series }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
series: String,
|
series: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
bookItems: {
|
bookItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -21,7 +24,10 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.series.name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<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="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 || []
|
||||||
|
},
|
||||||
|
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">
|
||||||
@@ -14,42 +14,17 @@
|
|||||||
</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 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)">
|
<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-icons 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,66 +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,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
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: '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: {
|
||||||
@@ -132,47 +56,10 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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)
|
||||||
if (parts.length > 1) {
|
return filter ? filter.text : ''
|
||||||
return this.$decode(parts[1])
|
|
||||||
}
|
|
||||||
var _sel = this.items.find((i) => i.value === this.selected)
|
|
||||||
if (!_sel) return ''
|
|
||||||
return _sel.text
|
|
||||||
},
|
|
||||||
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 ['Read', 'Unread', 'In Progress']
|
|
||||||
},
|
|
||||||
sublistItems() {
|
|
||||||
return (this[this.sublist] || []).map((item) => {
|
|
||||||
return {
|
|
||||||
text: item,
|
|
||||||
value: this.$encode(item)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
@@ -185,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,56 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-80 ml-6 relative">
|
<div class="sm:w-80 w-full relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
<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">
|
||||||
<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 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 audiobookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<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="`/audiobook/${item.audiobook.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</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="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
|
||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<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.author)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item.author" />
|
<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" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<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/${$encode(item.series)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</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.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="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.tag)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.tag" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
|
</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" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,10 +88,12 @@ export default {
|
|||||||
isTyping: false,
|
isTyping: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
search: null,
|
search: null,
|
||||||
audiobookResults: [],
|
podcastResults: [],
|
||||||
|
bookResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
|
narratorResults: [],
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -83,10 +103,13 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickOption() {
|
||||||
|
this.clearResults()
|
||||||
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
@@ -96,10 +119,12 @@ export default {
|
|||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
this.lastSearch = null
|
this.lastSearch = null
|
||||||
this.audiobookResults = []
|
this.podcastResults = []
|
||||||
|
this.bookResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
|
this.narratorResults = []
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
@@ -128,7 +153,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=${value}&limit=3`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -136,10 +161,12 @@ export default {
|
|||||||
// Search was canceled
|
// Search was canceled
|
||||||
if (!this.isFetching) return
|
if (!this.isFetching) return
|
||||||
|
|
||||||
this.audiobookResults = searchResults.audiobooks || []
|
this.podcastResults = searchResults.podcast || []
|
||||||
|
this.bookResults = searchResults.book || []
|
||||||
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.narratorResults = searchResults.narrators || []
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
@@ -170,8 +197,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.globalSearchMenu {
|
.globalSearchMenu {
|
||||||
max-height: 80vh;
|
max-height: calc(100vh - 75px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
<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-icons" 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-icons 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-icons 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-icons text-2xl">arrow_left</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-normal 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>
|
||||||
|
<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-icons 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthor,
|
||||||
|
value: 'authors',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNarrator,
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelPublisher,
|
||||||
|
value: 'publishers',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSeriesProgress,
|
||||||
|
value: 'progress',
|
||||||
|
sublist: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bookItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSeries,
|
||||||
|
value: 'series',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAuthor,
|
||||||
|
value: 'authors',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelNarrator,
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelPublisher,
|
||||||
|
value: 'publishers',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
podcastItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
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: 'single',
|
||||||
|
name: this.$strings.LabelTracksSingleTrack
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'multi',
|
||||||
|
name: this.$strings.LabelTracksMultiTrack
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ebooks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ebook',
|
||||||
|
name: this.$strings.LabelHasEbook
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'supplementary',
|
||||||
|
name: this.$strings.LabelHasSupplementaryEbook
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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,218 @@
|
|||||||
|
<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-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-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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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,113 +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 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)">
|
|
||||||
<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,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: 'Title',
|
|
||||||
value: 'book.title'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Author (First Last)',
|
|
||||||
value: 'book.authorFL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Author (Last, First)',
|
|
||||||
value: 'book.authorLF'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Added At',
|
|
||||||
value: 'addedAt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Volume #',
|
|
||||||
value: 'book.volumeNumber'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Duration',
|
|
||||||
value: 'duration'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Size',
|
|
||||||
value: 'size'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedText() {
|
|
||||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
|
||||||
var _sel = this.items.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
|
|
||||||
}
|
|
||||||
this.showMenu = false
|
|
||||||
this.$nextTick(() => this.$emit('change', val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative 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">{{ playbackRate.toFixed(1) }}<span class="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 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
|
<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-2 left-0 right-0 w-full flex justify-center">
|
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||||
<div class="arrow-down" />
|
<div class="arrow-down" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||||
<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-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,9 @@ 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,
|
||||||
|
arrowLeft: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -80,8 +82,22 @@ export default {
|
|||||||
var newPlaybackRate = this.playbackRate - 0.1
|
var newPlaybackRate = this.playbackRate - 0.1
|
||||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||||
},
|
},
|
||||||
|
updateMenuPositions() {
|
||||||
|
if (!this.$refs.wrapper) return
|
||||||
|
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||||
|
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||||
|
|
||||||
|
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||||
|
} else {
|
||||||
|
this.menuLeft = -92
|
||||||
|
this.arrowLeft = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
setShowMenu(val) {
|
setShowMenu(val) {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
this.updateMenuPositions()
|
||||||
this.currentPlaybackRate = this.playbackRate
|
this.currentPlaybackRate = this.playbackRate
|
||||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||||
this.$emit('change', this.playbackRate)
|
this.$emit('change', this.playbackRate)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
|
<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="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 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">
|
||||||
|
<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-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
descending: Boolean,
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
var _selected = this.selected
|
||||||
|
if (!_selected) return ''
|
||||||
|
var _sel = this.items.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
|
||||||
|
}
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<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" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||||
|
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||||
|
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||||
|
</svg>
|
||||||
|
<div v-else class="w-full h-full relative">
|
||||||
|
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||||
|
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: String,
|
||||||
|
default: 'lg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showCoverBg: false,
|
||||||
|
coverContain: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
_author() {
|
||||||
|
return this.author || {}
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
|
imagePath() {
|
||||||
|
return this._author.imagePath
|
||||||
|
},
|
||||||
|
updatedAt() {
|
||||||
|
return this._author.updatedAt
|
||||||
|
},
|
||||||
|
imgSrc() {
|
||||||
|
if (!this.imagePath) return null
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
// Testing
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
imageLoaded() {
|
||||||
|
var aspectRatio = 1.25
|
||||||
|
if (this.$refs.wrapper) {
|
||||||
|
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
if (this.$refs.img) {
|
||||||
|
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||||
|
var imgAr = naturalHeight / naturalWidth
|
||||||
|
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||||
|
if (arDiff > 0.15) {
|
||||||
|
this.showCoverBg = true
|
||||||
|
} else {
|
||||||
|
this.showCoverBg = false
|
||||||
|
this.coverContain = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,20 +5,11 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="audiobook" 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" @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'" />
|
||||||
<div v-show="loading && audiobook" 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">
|
||||||
<div class="la-ball-spin-clockwise la-sm">
|
<widgets-loading-spinner />
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,17 +17,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>
|
||||||
@@ -44,11 +35,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
authorOverride: String,
|
|
||||||
width: {
|
width: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
@@ -75,12 +65,15 @@ export default {
|
|||||||
height() {
|
height() {
|
||||||
return this.width * this.bookCoverAspectRatio
|
return this.width * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
if (!this.audiobook) return {}
|
if (!this.libraryItem) return {}
|
||||||
return this.audiobook.book || {}
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
titleCleaned() {
|
titleCleaned() {
|
||||||
if (this.title.length > 60) {
|
if (this.title.length > 60) {
|
||||||
@@ -88,9 +81,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
|
authors() {
|
||||||
|
return this.mediaMetadata.authors || []
|
||||||
|
},
|
||||||
author() {
|
author() {
|
||||||
if (this.authorOverride) return this.authorOverride
|
return this.authors.map((au) => au.name).join(', ')
|
||||||
return this.book.author || 'Unknown'
|
|
||||||
},
|
},
|
||||||
authorCleaned() {
|
authorCleaned() {
|
||||||
if (this.author.length > 30) {
|
if (this.author.length > 30) {
|
||||||
@@ -99,18 +94,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.audiobook) return null
|
if (!this.libraryItem) return null
|
||||||
var store = this.$store || this.$nuxt.$store
|
var store = this.$store || this.$nuxt.$store
|
||||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.book.cover
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||||
@@ -130,6 +126,9 @@ export default {
|
|||||||
},
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
resolution() {
|
||||||
|
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -138,12 +137,12 @@ export default {
|
|||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hideCoverBg() {},
|
|
||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.imageReady = true
|
this.imageReady = true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
var aspectRatio = naturalHeight / naturalWidth
|
||||||
@@ -168,214 +167,3 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/*!
|
|
||||||
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
|
|
||||||
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
|
|
||||||
* Licensed under MIT
|
|
||||||
*/
|
|
||||||
.la-ball-spin-clockwise,
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: relative;
|
|
||||||
-webkit-box-sizing: border-box;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
display: block;
|
|
||||||
font-size: 0;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-dark {
|
|
||||||
color: #262626;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
display: inline-block;
|
|
||||||
float: none;
|
|
||||||
background-color: currentColor;
|
|
||||||
border: 0 solid currentColor;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
margin-top: -4px;
|
|
||||||
margin-left: -4px;
|
|
||||||
border-radius: 100%;
|
|
||||||
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(1) {
|
|
||||||
top: 5%;
|
|
||||||
left: 50%;
|
|
||||||
-webkit-animation-delay: -0.875s;
|
|
||||||
-moz-animation-delay: -0.875s;
|
|
||||||
-o-animation-delay: -0.875s;
|
|
||||||
animation-delay: -0.875s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(2) {
|
|
||||||
top: 18.1801948466%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.75s;
|
|
||||||
-moz-animation-delay: -0.75s;
|
|
||||||
-o-animation-delay: -0.75s;
|
|
||||||
animation-delay: -0.75s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(3) {
|
|
||||||
top: 50%;
|
|
||||||
left: 95%;
|
|
||||||
-webkit-animation-delay: -0.625s;
|
|
||||||
-moz-animation-delay: -0.625s;
|
|
||||||
-o-animation-delay: -0.625s;
|
|
||||||
animation-delay: -0.625s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(4) {
|
|
||||||
top: 81.8198051534%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.5s;
|
|
||||||
-moz-animation-delay: -0.5s;
|
|
||||||
-o-animation-delay: -0.5s;
|
|
||||||
animation-delay: -0.5s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(5) {
|
|
||||||
top: 94.9999999966%;
|
|
||||||
left: 50.0000000005%;
|
|
||||||
-webkit-animation-delay: -0.375s;
|
|
||||||
-moz-animation-delay: -0.375s;
|
|
||||||
-o-animation-delay: -0.375s;
|
|
||||||
animation-delay: -0.375s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(6) {
|
|
||||||
top: 81.8198046966%;
|
|
||||||
left: 18.1801949248%;
|
|
||||||
-webkit-animation-delay: -0.25s;
|
|
||||||
-moz-animation-delay: -0.25s;
|
|
||||||
-o-animation-delay: -0.25s;
|
|
||||||
animation-delay: -0.25s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(7) {
|
|
||||||
top: 49.9999750815%;
|
|
||||||
left: 5.0000051215%;
|
|
||||||
-webkit-animation-delay: -0.125s;
|
|
||||||
-moz-animation-delay: -0.125s;
|
|
||||||
-o-animation-delay: -0.125s;
|
|
||||||
animation-delay: -0.125s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(8) {
|
|
||||||
top: 18.179464974%;
|
|
||||||
left: 18.1803700518%;
|
|
||||||
-webkit-animation-delay: 0s;
|
|
||||||
-moz-animation-delay: 0s;
|
|
||||||
-o-animation-delay: 0s;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm > div {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
margin-top: -2px;
|
|
||||||
margin-left: -2px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x > div {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-left: -8px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x > div {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: -12px;
|
|
||||||
margin-left: -12px;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* Animation
|
|
||||||
*/
|
|
||||||
@-webkit-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-moz-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-o-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
<div v-else-if="books.length" class="flex 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" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<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() {
|
||||||
@@ -44,6 +43,14 @@ export default {
|
|||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.isInit = false
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -51,9 +58,6 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
@@ -63,7 +67,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getCoverUrl(book) {
|
getCoverUrl(book) {
|
||||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||||
},
|
},
|
||||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||||
var src = coverData.coverUrl
|
var src = coverData.coverUrl
|
||||||
@@ -134,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
|
||||||
@@ -151,7 +155,6 @@ export default {
|
|||||||
.map((bookItem) => {
|
.map((bookItem) => {
|
||||||
return {
|
return {
|
||||||
id: bookItem.id,
|
id: bookItem.id,
|
||||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
|
||||||
coverUrl: this.getCoverUrl(bookItem)
|
coverUrl: this.getCoverUrl(bookItem)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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['audiobooks/getBookCoverSrc'](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>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative overflow-hidden">
|
||||||
<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-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
<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 src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -29,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,6 +60,13 @@ export default {
|
|||||||
},
|
},
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
resolution() {
|
||||||
|
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||||
|
},
|
||||||
|
placeholderUrl() {
|
||||||
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -63,7 +76,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>
|
|
||||||
@@ -1,87 +1,116 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="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 -mx-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
<ui-text-input-with-label v-model="newUser.username" :label="$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" class="mx-2" />
|
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="px-2">
|
<div class="px-2 w-52">
|
||||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
<div class="flex items-center pt-4 px-2">
|
||||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
<p class="px-3 font-semibold" 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 All Libraries</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.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<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 class="flex items-cen~ter my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
|
<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">
|
<div class="flex pt-4 px-2">
|
||||||
|
<ui-btn v-if="isEditingRoot" 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>
|
||||||
@@ -103,7 +132,8 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
accountTypes: ['guest', 'user', 'admin']
|
tags: [],
|
||||||
|
loadingTags: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -124,8 +154,27 @@ 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() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
@@ -135,9 +184,45 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItems() {
|
libraryItems() {
|
||||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||||
|
},
|
||||||
|
itemTags() {
|
||||||
|
return this.tags.map((t) => {
|
||||||
|
return {
|
||||||
|
text: t,
|
||||||
|
value: t
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tagsSelectionText() {
|
||||||
|
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
close() {
|
||||||
|
// Force close when navigating - used in UsersTable
|
||||||
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
|
},
|
||||||
|
accessAllTagsToggled(val) {
|
||||||
|
if (val) {
|
||||||
|
if (this.newUser.itemTagsSelected?.length) {
|
||||||
|
this.newUser.itemTagsSelected = []
|
||||||
|
}
|
||||||
|
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchAllTags() {
|
||||||
|
this.loadingTags = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/tags`)
|
||||||
|
.then((res) => {
|
||||||
|
this.tags = res.tags
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load tags', error)
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
},
|
||||||
accessAllLibrariesToggled(val) {
|
accessAllLibrariesToggled(val) {
|
||||||
if (!val && !this.newUser.librariesAccessible.length) {
|
if (!val && !this.newUser.librariesAccessible.length) {
|
||||||
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||||
@@ -154,6 +239,10 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
||||||
|
this.$toast.error('Must select at least one tag')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
@@ -175,10 +264,16 @@ export default {
|
|||||||
.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)
|
||||||
this.$toast.success('Account updated')
|
|
||||||
|
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||||
|
console.log('Current user token was updated')
|
||||||
|
this.$store.commit('user/setUserToken', data.user.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -223,22 +318,27 @@ export default {
|
|||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin'
|
upload: type === 'admin',
|
||||||
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.fetchAllTags()
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
var librariesAccessible = this.account.librariesAccessible || []
|
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
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: [...librariesAccessible]
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
|
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.fetchAllTags()
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
@@ -249,7 +349,9 @@ export default {
|
|||||||
update: false,
|
update: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">Probe Audio File</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-icons">{{ 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('FFProbe failed')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.probingFile = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async copyFfprobeData() {
|
||||||
|
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="backup-scheduler" :width="700" :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.HeaderSetBackupSchedule }}</p>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdateNecessary }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
cronExpression: {
|
||||||
|
type: String,
|
||||||
|
default: '* * * * *'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newCronExpression: null,
|
||||||
|
isUpdated: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
expressionUpdated() {
|
||||||
|
this.isUpdated = this.newCronExpression !== this.cronExpression
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.newCronExpression = this.cronExpression
|
||||||
|
this.isUpdated = false
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
// If custom expression input is focused then unfocus it instead of submitting
|
||||||
|
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
|
||||||
|
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
var updatePayload = {
|
||||||
|
backupSchedule: this.newCronExpression
|
||||||
|
}
|
||||||
|
this.$store
|
||||||
|
.dispatch('updateServerSettings', updatePayload)
|
||||||
|
.then((success) => {
|
||||||
|
console.log('Updated Server Settings', success)
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
this.$emit('update:cronExpression', this.newCronExpression)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update server settings', error)
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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-icons icon-text">info_outlined</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-icons icon-text">info_outlined</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>
|
||||||
|
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
|
</div>
|
||||||
|
</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 v-if="show" class="w-full h-full">
|
<div v-if="show" class="w-full h-full">
|
||||||
<template v-for="bookmark in bookmarks">
|
<template v-for="bookmark in bookmarks">
|
||||||
<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 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 @submit.prevent="submitCreateBookmark">
|
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
@@ -19,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-icons text-2xl -mt-px">add</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +44,8 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
audiobookId: String
|
libraryItemId: String,
|
||||||
|
hideCreate: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -67,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: {
|
||||||
@@ -76,8 +88,15 @@ export default {
|
|||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
deleteBookmark(bm) {
|
deleteBookmark(bm) {
|
||||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
this.$axios
|
||||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBookmark(bm) {
|
clickBookmark(bm) {
|
||||||
@@ -85,21 +104,34 @@ export default {
|
|||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
var bookmark = { ...updatedBookmark }
|
||||||
bookmark.audiobookId = this.audiobookId
|
this.$axios
|
||||||
|
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
this.$root.socket.emit('update_bookmark', bookmark)
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||||
|
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 = {
|
||||||
audiobookId: this.audiobookId,
|
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: this.currentTime
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
this.$root.socket.emit('create_bookmark', bookmark)
|
this.$axios
|
||||||
|
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
|
||||||
this.newBookmarkTitle = ''
|
this.newBookmarkTitle = ''
|
||||||
this.showBookmarkTitleInput = false
|
this.showBookmarkTitleInput = false
|
||||||
@@ -108,4 +140,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
|
||||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
|
||||||
<div class="overflow-y-hidden overflow-x-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<template v-for="texture in textures">
|
|
||||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
|
||||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
|
||||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
|
||||||
<span class="material-icons text-4xl text-success">check</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="flex pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showBookshelfTextureModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedBookshelfTexture() {
|
|
||||||
return this.$store.state.selectedBookshelfTexture
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {},
|
|
||||||
setTexture(img) {
|
|
||||||
this.$store.dispatch('setBookshelfTexture', img)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div 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">
|
||||||
<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 hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
{{ chap.title }}
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
|
{{ chap.title }}
|
||||||
|
</p>
|
||||||
|
<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-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>
|
||||||
@@ -25,7 +28,8 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -44,11 +48,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 ? this.currentChapter.id : null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return this.currentChapter ? this.currentChapter.start : 0
|
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -58,16 +66,25 @@ 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 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#chapter-modal-wrapper .chapter-title {
|
||||||
|
max-width: calc(100% - 120px);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
#chapter-modal-wrapper .chapter-title {
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" :width="300" height="100%">
|
||||||
|
<template #outer>
|
||||||
|
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||||
|
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="relative flex items-center px-3">
|
||||||
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
title: String,
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selected: String // optional
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickedOption(action) {
|
||||||
|
this.$emit('action', { action })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
|
||||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
library: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.library ? 'Update Library' : 'New Library'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<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: 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">
|
||||||
|
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||||
|
</div>
|
||||||
|
<div ref="content" class="text-white">
|
||||||
|
<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="flex">
|
||||||
|
<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="$strings.LabelSeriesName" />
|
||||||
|
</div>
|
||||||
|
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||||
|
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2 p-1">
|
||||||
|
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
existingSeriesNames: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.$nextTick(this.setShow)
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isNewSeries() {
|
||||||
|
if (!this.selectedSeries || !this.selectedSeries.id) return false
|
||||||
|
return this.selectedSeries.id.startsWith('new')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setInputFocus() {
|
||||||
|
if (this.isNewSeries) {
|
||||||
|
// Focus on series input if new series
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.setFocus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Focus on sequence input if existing series
|
||||||
|
if (this.$refs.sequenceInput) {
|
||||||
|
this.$refs.sequenceInput.setFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitSeriesForm() {
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('submit')
|
||||||
|
},
|
||||||
|
clickClose() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setShow() {
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'scale(1)'
|
||||||
|
}, 10)
|
||||||
|
|
||||||
|
this.$store.commit('setInnerModalOpen', true)
|
||||||
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
|
||||||
|
this.setInputFocus()
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
if (this.content) this.content.style.transform = 'scale(0)'
|
||||||
|
if (this.el) this.el.remove()
|
||||||
|
|
||||||
|
this.$store.commit('setInnerModalOpen', false)
|
||||||
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
if (this.content && this.el) {
|
||||||
|
this.el.classList.remove('hidden')
|
||||||
|
this.el.classList.add('flex')
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.style.opacity = 1
|
||||||
|
this.el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||||
|
</div>
|
||||||
|
</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 class="flex items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap mb-4">
|
||||||
|
<div class="w-full md:w-2/3">
|
||||||
|
<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="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelTimeListened }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartTime }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.startTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLastTime }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||||
|
<div class="px-1 text-xs">
|
||||||
|
{{ _session.libraryId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||||
|
<div class="px-1 text-xs">
|
||||||
|
{{ _session.libraryItemId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||||
|
<div class="px-1 text-xs">
|
||||||
|
{{ _session.episodeId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelMediaType }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.mediaType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelDuration }}</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPretty(_session.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">{{ $strings.LabelUser }}</p>
|
||||||
|
<p class="mb-1 text-xs">{{ _session.userId }}</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">{{ _session.mediaPlayer }}</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="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||||
|
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||||
|
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
|
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
session: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_session() {
|
||||||
|
return this.session || {}
|
||||||
|
},
|
||||||
|
deviceInfo() {
|
||||||
|
return this._session.deviceInfo || {}
|
||||||
|
},
|
||||||
|
hasDeviceInfo() {
|
||||||
|
return Object.keys(this.deviceInfo).length
|
||||||
|
},
|
||||||
|
osDisplayName() {
|
||||||
|
if (!this.deviceInfo.osName) return null
|
||||||
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
|
},
|
||||||
|
clientDisplayName() {
|
||||||
|
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||||
|
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||||
|
},
|
||||||
|
playMethodName() {
|
||||||
|
const playMethod = this._session.playMethod
|
||||||
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
|
return 'Unknown'
|
||||||
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
},
|
||||||
|
isOpenSession() {
|
||||||
|
return !!this._session.open
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
deleteSessionClick() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmDeleteSession,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.deleteSession()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
deleteSession() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/sessions/${this._session.id}`)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success(this.$strings.ToastSessionDeleteSuccess)
|
||||||
|
this.$emit('removedSession')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.processing = false
|
||||||
|
console.error('Failed to delete session', error)
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeSessionClick() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/session/${this._session.id}/close`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Session closed')
|
||||||
|
this.show = false
|
||||||
|
this.$emit('closedSession')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close session', error)
|
||||||
|
const errMsg = error.response?.data || ''
|
||||||
|
this.$toast.error(errMsg || 'Failed to close open session')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</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-5 right-5 h-12 w-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-4xl">close</span>
|
<span class="material-icons 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: 400px; min-height: 200px" 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,
|
||||||
@@ -50,7 +50,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null,
|
||||||
preventClickoutside: false
|
preventClickoutside: false,
|
||||||
|
isShowingPrompt: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -93,16 +94,18 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBg(ev) {
|
clickBg(ev) {
|
||||||
|
if (!this.show || this.isShowingPrompt) return
|
||||||
if (this.preventClickoutside) {
|
if (this.preventClickoutside) {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
|
if (this.$store.state.innerModalOpen) return
|
||||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@@ -145,8 +148,16 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('Invalid modal init', this.name)
|
console.warn('Invalid modal init', this.name)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
showingPrompt(isShowing) {
|
||||||
|
this.isShowingPrompt = isShowing
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.$eventBus.$on('showing-prompt', this.showingPrompt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -2,17 +2,21 @@
|
|||||||
<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-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="!timerSet" class="w-full">
|
<div v-if="!timerSet" 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-bg relative" @click="setTime(time.seconds)">
|
||||||
<p class="text-xl text-center">{{ time.text }}</p>
|
<p class="text-xl 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="Time in minutes" class="w-48" />
|
||||||
|
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full p-4">
|
<div v-else class="w-full p-4">
|
||||||
<div class="mb-4 flex items-center justify-center">
|
<div class="mb-4 flex items-center justify-center">
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
<span class="pl-1 text-base font-mono">30m</span>
|
<span class="pl-1 text-base font-mono">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>
|
||||||
@@ -48,19 +52,28 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
customTime: null,
|
||||||
sleepTimes: [
|
sleepTimes: [
|
||||||
{
|
|
||||||
seconds: 10,
|
|
||||||
text: '10 seconds'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
seconds: 60 * 5,
|
seconds: 60 * 5,
|
||||||
text: '5 minutes'
|
text: '5 minutes'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 15,
|
||||||
|
text: '15 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 20,
|
||||||
|
text: '20 minutes'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
seconds: 60 * 30,
|
seconds: 60 * 30,
|
||||||
text: '30 minutes'
|
text: '30 minutes'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 45,
|
||||||
|
text: '45 minutes'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
seconds: 60 * 60,
|
seconds: 60 * 60,
|
||||||
text: '60 minutes'
|
text: '60 minutes'
|
||||||
@@ -72,10 +85,6 @@ export default {
|
|||||||
{
|
{
|
||||||
seconds: 60 * 120,
|
seconds: 60 * 120,
|
||||||
text: '2 hours'
|
text: '2 hours'
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 180,
|
|
||||||
text: '3 hours'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -97,8 +106,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setTime(time) {
|
submitCustomTime() {
|
||||||
this.$emit('set', time.seconds)
|
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||||
|
this.customTime = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||||
|
this.setTime(timeInSeconds)
|
||||||
|
},
|
||||||
|
setTime(seconds) {
|
||||||
|
this.$emit('set', seconds)
|
||||||
},
|
},
|
||||||
increment(amount) {
|
increment(amount) {
|
||||||
this.$emit('increment', amount)
|
this.$emit('increment', amount)
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="edit-author" :width="800" :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">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<div class="w-full h-45 relative">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-3/4 p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex pt-2 px-2">
|
||||||
|
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
authorCopy: {
|
||||||
|
name: '',
|
||||||
|
asin: '',
|
||||||
|
description: '',
|
||||||
|
imagePath: ''
|
||||||
|
},
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
author: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showEditAuthorModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
if (!this.author) return ''
|
||||||
|
return this.author.id
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.$strings.HeaderUpdateAuthor
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.authorCopy.name = this.author.name
|
||||||
|
this.authorCopy.asin = this.author.asin
|
||||||
|
this.authorCopy.description = this.author.description
|
||||||
|
this.authorCopy.imagePath = this.author.imagePath
|
||||||
|
},
|
||||||
|
async submitForm() {
|
||||||
|
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||||
|
var updatePayload = {}
|
||||||
|
keysToCheck.forEach((key) => {
|
||||||
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
|
updatePayload[key] = this.authorCopy[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!Object.keys(updatePayload).length) {
|
||||||
|
this.$toast.info(this.$strings.MessageNoUpdateNecessary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
const errorMsg = error.response ? error.response.data : null
|
||||||
|
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
|
if (result.updated) {
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
|
this.show = false
|
||||||
|
} else if (result.merged) {
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
||||||
|
this.show = false
|
||||||
|
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async removeCover() {
|
||||||
|
var updatePayload = {
|
||||||
|
imagePath: null
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result && result.updated) {
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', result.author)
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
|
this.$toast.error('Must enter an author name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const payload = {}
|
||||||
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
|
else payload.q = this.authorCopy.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) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error('Author not found')
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) {
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||||
|
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made for Author')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
<div class="flex-grow pr-2">
|
<div class="flex-grow pr-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">forward</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
|
||||||
<div class="pl-2 flex items-center">
|
<div class="pl-2 flex items-center">
|
||||||
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="changelog" :width="800" :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">Changelog</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||||
|
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||||
|
<div class="custom-text" v-html="compiledMarkedown" />
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { marked } from '@/static/libs/marked/index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
changelog: String,
|
||||||
|
currentVersion: String
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
compiledMarkedown() {
|
||||||
|
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||||
|
},
|
||||||
|
currentVersionNumber() {
|
||||||
|
return this.currentVersion
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||||
|
since we don't have access to the actual elements in this component
|
||||||
|
|
||||||
|
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||||
|
*/
|
||||||
|
.custom-text ::v-deep > h2 {
|
||||||
|
@apply text-lg font-bold;
|
||||||
|
}
|
||||||
|
.custom-text ::v-deep > h3 {
|
||||||
|
@apply text-lg font-bold;
|
||||||
|
}
|
||||||
|
.custom-text ::v-deep > ul {
|
||||||
|
@apply list-disc list-inside pb-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+57
-56
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="collections" :processing="processing" :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 pointer-events-none">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="text-3xl text-white truncate">{{ title }}</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-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="show" class="w-full h-full">
|
<div v-if="show" class="w-full h-full">
|
||||||
<div class="py-4 px-4">
|
<div class="py-4 px-4">
|
||||||
<h1 v-if="!showBatchUserCollectionModal" class="text-2xl">Add to Collection</h1>
|
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
|
||||||
<h1 v-else class="text-2xl">Add {{ selectedBookIds.length }} Books to Collection</h1>
|
<h1 v-else class="text-2xl">{{ $getString('LabelAddToCollectionBatch', [selectedBookIds.length]) }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||||
<transition-group name="list-complete" tag="div">
|
<transition-group name="list-complete" tag="div">
|
||||||
<template v-for="collection in sortedCollections">
|
<template v-for="collection in sortedCollections">
|
||||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
<modals-collections-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||||
</template>
|
</template>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
|
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
|
||||||
<p class="text-xl">No Collections</p>
|
<p class="text-xl">{{ $strings.MessageNoCollections }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||||
<form @submit.prevent="submitCreateCollection">
|
<form @submit.prevent="submitCreateCollection">
|
||||||
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<ui-text-input v-model="newCollectionName" placeholder="New Collection" class="w-full" />
|
<ui-text-input v-model="newCollectionName" :placeholder="$strings.PlaceholderNewCollection" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">Create</ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,44 +50,47 @@ export default {
|
|||||||
this.loadCollections()
|
this.loadCollections()
|
||||||
this.newCollectionName = ''
|
this.newCollectionName = ''
|
||||||
} else {
|
} else {
|
||||||
this.$store.commit('setSelectedAudiobook', null)
|
this.$store.commit('setSelectedLibraryItem', null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.$store.state.globals.showUserCollectionsModal
|
return this.$store.state.globals.showCollectionsModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$store.commit('globals/setShowUserCollectionsModal', val)
|
this.$store.commit('globals/setShowCollectionsModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchCollectionModal) {
|
||||||
return `${this.selectedBookIds.length} Books Selected`
|
return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])
|
||||||
}
|
}
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
|
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||||
},
|
|
||||||
selectedAudiobook() {
|
|
||||||
return this.$store.state.selectedAudiobook
|
|
||||||
},
|
|
||||||
selectedAudiobookId() {
|
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.id : null
|
|
||||||
},
|
},
|
||||||
collections() {
|
collections() {
|
||||||
return this.$store.state.user.collections || []
|
return this.$store.state.libraries.collections || []
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
selectedLibraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
|
||||||
},
|
},
|
||||||
sortedCollections() {
|
sortedCollections() {
|
||||||
return this.collections
|
return this.collections
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
var includesBook = false
|
var includesBook = false
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchCollectionModal) {
|
||||||
// Only show collection added if all books are in the collection
|
// Only show collection added if all books are in the collection
|
||||||
var collectionBookIds = c.books.map((b) => b.id)
|
var collectionBookIds = c.books.map((b) => b.id)
|
||||||
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
||||||
} else {
|
} else {
|
||||||
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
|
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -97,11 +100,11 @@ export default {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => (a.isBookIncluded ? -1 : 1))
|
.sort((a, b) => (a.isBookIncluded ? -1 : 1))
|
||||||
},
|
},
|
||||||
showBatchUserCollectionModal() {
|
showBatchCollectionModal() {
|
||||||
return this.$store.state.globals.showBatchUserCollectionModal
|
return this.$store.state.globals.showBatchCollectionModal
|
||||||
},
|
},
|
||||||
selectedBookIds() {
|
selectedBookIds() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
@@ -109,47 +112,61 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loadCollections() {
|
loadCollections() {
|
||||||
this.$store.dispatch('user/loadUserCollections')
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/libraries/${this.currentLibraryId}/collections`)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.results) {
|
||||||
|
this.$store.commit('libraries/setCollections', data.results || [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to get collections', error)
|
||||||
|
this.$toast.error('Failed to load collections')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
removeFromCollection(collection) {
|
removeFromCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchCollectionModal) {
|
||||||
// BATCH Remove books
|
// BATCH Remove books
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Books removed from collection`, updatedCollection)
|
console.log(`Books removed from collection`, updatedCollection)
|
||||||
this.$toast.success('Books removed from collection')
|
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove books from collection', error)
|
console.error('Failed to remove books from collection', error)
|
||||||
this.$toast.error('Failed to remove books from collection')
|
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Remove single book
|
// Remove single book
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
|
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book removed from collection`, updatedCollection)
|
console.log(`Book removed from collection`, updatedCollection)
|
||||||
this.$toast.success('Book removed from collection')
|
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove book from collection', error)
|
console.error('Failed to remove book from collection', error)
|
||||||
this.$toast.error('Failed to remove book from collection')
|
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addToCollection(collection) {
|
addToCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchCollectionModal) {
|
||||||
// BATCH Remove books
|
// BATCH Remove books
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||||
@@ -164,10 +181,10 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (!this.selectedAudiobookId) return
|
if (!this.selectedLibraryItemId) return
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
|
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book added to collection`, updatedCollection)
|
console.log(`Book added to collection`, updatedCollection)
|
||||||
this.$toast.success('Book added to collection')
|
this.$toast.success('Book added to collection')
|
||||||
@@ -181,12 +198,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
submitCreateCollection() {
|
submitCreateCollection() {
|
||||||
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
|
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
|
var books = this.showBatchCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
|
||||||
var newCollection = {
|
var newCollection = {
|
||||||
books: books,
|
books: books,
|
||||||
libraryId: this.currentLibraryId,
|
libraryId: this.currentLibraryId,
|
||||||
@@ -212,19 +229,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
|
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||||
|
<div class="w-20 max-w-20 text-center">
|
||||||
|
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
|
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||||
|
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
|
||||||
|
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
collection: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isBookIncluded() {
|
||||||
|
return !!this.collection.isBookIncluded
|
||||||
|
},
|
||||||
|
books() {
|
||||||
|
return this.collection.books || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickNuxtLink() {
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickAdd() {
|
||||||
|
this.$emit('add', this.collection)
|
||||||
|
},
|
||||||
|
clickRem() {
|
||||||
|
this.$emit('remove', this.collection)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user