Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support

This commit is contained in:
Juan D. Jara
2021-09-27 02:24:30 +02:00
411 changed files with 35232 additions and 20531 deletions

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#31C48D" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#F7FAFC" fill-rule="evenodd" d="M36.707 9.29303C36.8945 9.48056 36.9998 9.73487 36.9998 10C36.9998 10.2652 36.8945 10.5195 36.707 10.707L28.707 18.707C28.5195 18.8945 28.2652 18.9998 28 18.9998C27.7348 18.9998 27.4805 18.8945 27.293 18.707L23.293 14.707C23.1108 14.5184 23.0101 14.2658 23.0123 14.0036C23.0146 13.7414 23.1198 13.4906 23.3052 13.3052C23.4906 13.1198 23.7414 13.0146 24.0036 13.0124C24.2658 13.0101 24.5184 13.1109 24.707 13.293L28 16.586L35.293 9.29303C35.4805 9.10556 35.7348 9.00024 36 9.00024C36.2652 9.00024 36.5195 9.10556 36.707 9.29303Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 744 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 250 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-icon="discord" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="currentColor" d="M297.216 243.2c0 15.616-11.52 28.416-26.112 28.416-14.336 0-26.112-12.8-26.112-28.416s11.52-28.416 26.112-28.416c14.592 0 26.112 12.8 26.112 28.416zm-119.552-28.416c-14.592 0-26.112 12.8-26.112 28.416s11.776 28.416 26.112 28.416c14.592 0 26.112-12.8 26.112-28.416.256-15.616-11.52-28.416-26.112-28.416zM448 52.736V512c-64.494-56.994-43.868-38.128-118.784-107.776l13.568 47.36H52.48C23.552 451.584 0 428.032 0 398.848V52.736C0 23.552 23.552 0 52.48 0h343.04C424.448 0 448 23.552 448 52.736zm-72.96 242.688c0-82.432-36.864-149.248-36.864-149.248-36.864-27.648-71.936-26.88-71.936-26.88l-3.584 4.096c43.52 13.312 63.744 32.512 63.744 32.512-60.811-33.329-132.244-33.335-191.232-7.424-9.472 4.352-15.104 7.424-15.104 7.424s21.248-20.224 67.328-33.536l-2.56-3.072s-35.072-.768-71.936 26.88c0 0-36.864 66.816-36.864 149.248 0 0 21.504 37.12 78.08 38.912 0 0 9.472-11.52 17.152-21.248-32.512-9.728-44.8-30.208-44.8-30.208 3.766 2.636 9.976 6.053 10.496 6.4 43.21 24.198 104.588 32.126 159.744 8.96 8.96-3.328 18.944-8.192 29.44-15.104 0 0-12.8 20.992-46.336 30.464 7.68 9.728 16.896 20.736 16.896 20.736 56.576-1.792 78.336-38.912 78.336-38.912z"/></svg>
<svg viewBox="0 0 71 71" xmlns="http://www.w3.org/2000/svg"><path transform="translate(-1.0994e-7 8.0294)" d="m60.104 4.8978c-4.5253-2.0764-9.378-3.6062-14.452-4.4824-0.0924-0.01691-0.1847 0.025349-0.2323 0.10987-0.6241 1.11-1.3154 2.5581-1.7995 3.6963-5.4572-0.817-10.886-0.817-16.232 0-0.4842-1.1635-1.2006-2.5863-1.8275-3.6963-0.0476-0.0817-0.1399-0.12396-0.2323-0.10987-5.071 0.87338-9.9237 2.4032-14.452 4.4824-0.0392 0.0169-0.0728 0.0451-0.0951 0.0817-9.2046 13.751-11.726 27.165-10.489 40.412 0.005597 0.0648 0.041978 0.1268 0.092353 0.1662 6.0729 4.4598 11.956 7.1673 17.729 8.9619 0.0924 0.0282 0.1903-0.0056 0.2491-0.0817 1.3657-1.865 2.5831-3.8315 3.6269-5.8995 0.0616-0.1211 0.0028-0.2648-0.1231-0.3127-1.931-0.7325-3.7697-1.6256-5.5384-2.6398-0.1399-0.0817-0.1511-0.2818-0.0224-0.3776 0.3722-0.2789 0.7445-0.5691 1.0999-0.8621 0.0643-0.0535 0.1539-0.0648 0.2295-0.031 11.62 5.3051 24.199 5.3051 35.682 0 0.0756-0.0366 0.1652-0.0253 0.2323 0.0282 0.3555 0.293 0.7277 0.586 1.1027 0.8649 0.1287 0.0958 0.1203 0.2959-0.0196 0.3776-1.7687 1.0339-3.6074 1.9073-5.5412 2.637-0.1259 0.0479-0.1819 0.1944-0.1203 0.3155 1.0662 2.0651 2.2836 4.0316 3.6241 5.8967 0.056 0.0789 0.1567 0.1127 0.2491 0.0845 5.8014-1.7946 11.684-4.5021 17.757-8.9619 0.0532-0.0394 0.0868-0.0986 0.0924-0.1634 1.4804-15.315-2.4796-28.618-10.498-40.412-0.0196-0.0394-0.0531-0.0676-0.0923-0.0845zm-36.379 32.428c-3.4983 0-6.3808-3.2117-6.3808-7.156s2.8266-7.156 6.3808-7.156c3.5821 0 6.4367 3.2399 6.3807 7.156 0 3.9443-2.8266 7.156-6.3807 7.156zm23.592 0c-3.4982 0-6.3807-3.2117-6.3807-7.156s2.8265-7.156 6.3807-7.156c3.5822 0 6.4367 3.2399 6.3808 7.156 0 3.9443-2.7986 7.156-6.3808 7.156z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 750 750" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="m554.69 180.46c-333.63 0-452.75 389.23-556.05 389.23 185.37 0 237.85-247.18 419.12-247.18l47.24-102.05z"/><path d="m749.31 375.08c0 107.48-87.14 194.61-194.62 194.61s-194.62-87.13-194.62-194.61 87.13-194.62 194.62-194.62c7.391-2e-3 14.776 0.412 22.12 1.24-78.731 10.172-136.59 78.893-133.2 158.2 3.393 79.313 66.907 142.84 146.22 146.25 79.311 3.411 148.05-54.43 158.24-133.16 0.826 7.331 1.24 14.703 1.24 22.08z"/></g></svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path d="m128 0c-70.692 0-128 57.308-128 128s57.308 128 128 128 128-57.308 128-128-57.308-128-128-128zm-51 64.268h3.334c16.733 0 16.732-5.78e-4 16.732 16.732v91.867c-1e-6 16.733 5.77e-4 16.732-16.732 16.732h-3.334c-16.733 0-16.732 5.8e-4 -16.732-16.732v-91.867c0-16.733-5.78e-4 -16.732 16.732-16.732zm44.041 0h37.537c32.178 0 52.627 32.273 52.627 63.025 0 30.752-20.627 62.307-52.627 62.307h-37.537c-5.699 0-8.5078-2.8088-8.5078-8.5078v-108.32c0-5.698 2.8088-8.5059 8.5078-8.5059z" fill="#fff" mask="url(#mask-2)"/></svg>
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><path d="m128 0c-70.692 0-128 57.308-128 128s57.308 128 128 128 128-57.308 128-128-57.308-128-128-128zm-51 64.268h3.334c16.733 0 16.732-5.78e-4 16.732 16.732v91.867c-1e-6 16.733 5.77e-4 16.732-16.732 16.732h-3.334c-16.733 0-16.732 5.8e-4 -16.732-16.732v-91.867c0-16.733-5.78e-4 -16.732 16.732-16.732zm44.041 0h37.537c32.178 0 52.627 32.273 52.627 63.025s-20.627 62.307-52.627 62.307h-37.537c-5.699 0-8.5078-2.8088-8.5078-8.5078v-108.32c0-5.698 2.8088-8.5059 8.5078-8.5059z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="57 57 602 602"><g id="layer1" fill="#1F2937" fill-rule="evenodd" stroke="none" stroke-width="1" opacity=".91" transform="translate(58.964119, 58.887520)"><ellipse style="fill:#fff;fill-rule:evenodd;stroke:#fff;stroke-width:0" cx="216.308" cy="152.076" rx="296.855" ry="296.855" transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"/><path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill-rule:nonzero;white-space:pre"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="57 57 593.71 593.71"><g transform="translate(55.201 55.147)"><circle cx="216.31" cy="152.08" r="296.86" fill="currentColor" transform="rotate(132.42 225.16 243.54)"/><path d="m280.95 172.51 74.48-9.8-72.52 163.66c12.74-0.98 25.233-5.307 37.48-12.98 12.253-7.68 23.527-17.317 33.82-28.91 10.287-11.6 19.187-24.503 26.7-38.71 7.513-14.213 12.903-28.18 16.17-41.9 1.96-8.493 2.86-16.66 2.7-24.5-0.167-7.84-2.21-14.7-6.13-20.58s-9.883-10.617-17.89-14.21c-8-3.593-18.86-5.39-32.58-5.39-16.007 0-31.77 2.613-47.29 7.84-15.513 5.227-29.887 12.823-43.12 22.79-13.227 9.96-24.74 22.373-34.54 37.24-9.8 14.86-16.823 31.763-21.07 50.71-1.633 6.207-2.613 11.187-2.94 14.94-0.327 3.76-0.407 6.863-0.24 9.31 0.16 2.453 0.483 4.333 0.97 5.64 0.493 1.307 0.903 2.613 1.23 3.92-16.66 0-28.83-3.35-36.51-10.05-7.673-6.693-9.55-18.37-5.63-35.03 3.92-17.313 12.823-33.81 26.71-49.49 13.88-15.68 30.373-29.483 49.48-41.41 19.113-11.92 40.02-21.39 62.72-28.41 22.707-7.027 44.84-10.54 66.4-10.54 18.947 0 34.87 2.693 47.77 8.08 12.907 5.393 22.953 12.5 30.14 21.32s11.677 19.11 13.47 30.87c1.8 11.76 1.23 24.01-1.71 36.75-3.593 15.353-10.373 30.79-20.34 46.31-9.96 15.513-22.453 29.56-37.48 42.14-15.027 12.573-32.26 22.78-51.7 30.62-19.433 7.84-40.093 11.76-61.98 11.76h-2.45l-62.23 139.65h-70.56z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-icon="slack" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="currentColor" d="M94.12 315.1c0 25.9-21.16 47.06-47.06 47.06S0 341 0 315.1c0-25.9 21.16-47.06 47.06-47.06h47.06v47.06zm23.72 0c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06v117.84c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06V315.1zm47.06-188.98c-25.9 0-47.06-21.16-47.06-47.06S139 32 164.9 32s47.06 21.16 47.06 47.06v47.06H164.9zm0 23.72c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06H47.06C21.16 243.96 0 222.8 0 196.9s21.16-47.06 47.06-47.06H164.9zm188.98 47.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06h-47.06V196.9zm-23.72 0c0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06V79.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06V196.9zM283.1 385.88c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06v-47.06h47.06zm0-23.72c-25.9 0-47.06-21.16-47.06-47.06 0-25.9 21.16-47.06 47.06-47.06h117.84c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06H283.1z"/></svg>
<svg viewBox="0 0 448 448" xmlns="http://www.w3.org/2000/svg"><path d="m94.12 283.1c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06 21.16-47.06 47.06-47.06h47.06zm23.72 0c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06v117.84c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06zm47.06-188.98c-25.9 0-47.06-21.16-47.06-47.06s21.16-47.06 47.06-47.06 47.06 21.16 47.06 47.06v47.06zm0 23.72c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06h-117.84c-25.9 0-47.06-21.16-47.06-47.06s21.16-47.06 47.06-47.06zm188.98 47.06c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06-21.16 47.06-47.06 47.06h-47.06zm-23.72 0c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06v-117.84c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06zm-47.06 188.98c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06-47.06-21.16-47.06-47.06v-47.06zm0-23.72c-25.9 0-47.06-21.16-47.06-47.06s21.16-47.06 47.06-47.06h117.84c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1014 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48"><path fill="currentColor" fill-rule="evenodd" d="M0 24C0 37.2548 10.7452 48 24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24ZM19.6 35L20.0083 28.8823L20.008 28.882L31.1369 18.839C31.6253 18.4055 31.0303 18.1941 30.3819 18.5873L16.6473 27.2523L10.7147 25.4007C9.4335 25.0084 9.4243 24.128 11.0023 23.4951L34.1203 14.5809C35.1762 14.1015 36.1953 14.8345 35.7922 16.4505L31.8552 35.0031C31.5803 36.3215 30.7837 36.6368 29.68 36.0278L23.6827 31.5969L20.8 34.4C20.7909 34.4088 20.7819 34.4176 20.7729 34.4264C20.4505 34.7403 20.1837 35 19.6 35Z" clip-rule="evenodd"/></svg>
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><path d="m0 24c0 13.255 10.745 24 24 24s24-10.745 24-24-10.745-24-24-24-24 10.745-24 24zm19.6 11l0.4083-6.1177-3e-4 -3e-4 11.129-10.043c0.4884-0.4335-0.1066-0.6449-0.755-0.2517l-13.735 8.665-5.9326-1.8516c-1.2812-0.3923-1.2904-1.2727 0.2876-1.9056l23.118-8.9142c1.0559-0.4794 2.075 0.2536 1.6719 1.8696l-3.937 18.553c-0.2749 1.3184-1.0715 1.6337-2.1752 1.0247l-5.9973-4.4309-2.8827 2.8031c-0.0091 0.0088-0.0181 0.0176-0.0271 0.0264-0.3224 0.3139-0.5892 0.5736-1.1729 0.5736z" clip-rule="evenodd" fill="currentColor" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#ED8936" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#fff" d="M31 5.79999C29.5678 5.79999 28.1943 6.36891 27.1816 7.38161C26.1689 8.39431 25.6 9.76782 25.6 11.2V14.4274L24.9637 15.0637C24.8379 15.1896 24.7522 15.3499 24.7175 15.5245C24.6828 15.699 24.7006 15.8799 24.7687 16.0444C24.8368 16.2088 24.9521 16.3494 25.1001 16.4482C25.2481 16.5471 25.422 16.5999 25.6 16.6H36.4C36.578 16.5999 36.7519 16.5471 36.8999 16.4482C37.0479 16.3494 37.1632 16.2088 37.2313 16.0444C37.2994 15.8799 37.3172 15.699 37.2825 15.5245C37.2478 15.3499 37.1621 15.1896 37.0363 15.0637L36.4 14.4274V11.2C36.4 9.76782 35.8311 8.39431 34.8184 7.38161C33.8057 6.36891 32.4322 5.79999 31 5.79999ZM31 20.2C30.2839 20.2 29.5972 19.9155 29.0908 19.4092C28.5845 18.9028 28.3 18.2161 28.3 17.5H33.7C33.7 18.2161 33.4155 18.9028 32.9092 19.4092C32.4028 19.9155 31.7161 20.2 31 20.2Z"/></svg>

Before

Width:  |  Height:  |  Size: 962 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 560"><g fill="none"><path fill="#FFF" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></g></svg>
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,8 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 560">
<g fill="none" transform="translate(33 140)">
<path fill="#FFF" d="M43.8020066,267.3152 L281.745403,290.797105 C286.539148,305.7344 292.894623,320.31421 302.004138,331.0528 L71.7380852,300.927619 C61.4905377,294.175695 50.8770689,281.050362 43.8020066,267.3152 Z M266.684852,192.017143 C267.285384,212.923048 270.116459,239.981562 275.03101,263.034133 L33.8766098,243.950705 C26.3585902,221.21181 24.03,207.991848 22.1723803,189.066133 L266.684852,192.017143 Z M275.03101,89.3083429 C270.116459,112.360914 267.285384,139.419429 266.684852,160.325333 L22.1723803,163.276343 C24.03,144.350629 26.3585902,131.130667 33.8766098,108.391771 L275.03101,89.3083429 Z M302.004138,21.2896762 C292.894623,32.030019 286.539148,46.6080762 281.745403,61.5471238 L43.8020066,85.0272762 C50.8770689,71.2921143 61.4905377,58.1685333 71.7380852,51.4148571 L302.004138,21.2896762 Z"/>
<path fill="#00641E" d="M303.565869,264.667352 C306.720846,256.846476 317.903331,252.081752 325.93259,252.63901 C334.515108,253.234819 343.631626,262.264838 345.224872,271.145905 C345.520761,270.823467 345.830656,270.518552 346.145803,270.218895 C348.901593,267.583314 352.35421,265.834438 356.132479,265.352533 C355.554708,262.790552 355.416393,260.048076 355.812079,257.233752 C357.207482,247.3328 365.145698,239.907962 374.234203,239.981562 C380.099449,240.028876 385.245108,243.032457 388.597928,247.646476 C388.897318,247.271467 389.228223,246.928 389.550374,246.577524 C393.384669,226.586362 395.814807,203.999924 396.368066,180.030857 C398.267705,97.6111238 377.375174,30.2776381 349.70522,29.6397714 C322.033515,29.0001524 298.061292,95.2962286 296.161652,177.715962 C296.161652,177.715962 294.696216,207.778057 303.565869,264.667352"/>
<path fill="#FFD700" d="M490.910577,354.797562 C492.545843,352.0656 493.45977,348.7904 493.396741,345.310171 C493.927239,334.065143 486.214879,323.871543 475.484105,325.000076 C475.794,323.713829 475.997095,322.376762 476.075882,320.997638 C476.718433,309.733333 468.957049,300.025143 458.739266,299.315429 C458.515161,299.30141 458.294557,299.2944 458.072203,299.28739 C459.134951,296.492343 459.661948,293.371352 459.47461,290.06461 C458.945862,280.692876 452.488839,272.796648 444.088407,271.242286 C441.054236,270.681524 438.111108,270.960152 435.416597,271.894171 C432.870905,265.617143 427.525652,260.961067 421.023108,259.977981 C420.508367,249.786133 413.153174,241.397486 403.68299,240.740343 C397.728452,240.326781 392.257141,243.064 388.597928,247.646476 C385.245108,243.032457 380.099449,240.030629 374.234203,239.983314 C365.145698,239.907962 357.207482,247.3328 355.812079,257.235505 C355.416393,260.048076 355.554708,262.790552 356.132479,265.354286 C352.35421,265.834438 348.901593,267.585067 346.145803,270.218895 C345.830656,270.518552 345.520761,270.823467 345.224872,271.145905 C343.631626,262.264838 334.515108,253.236571 325.93259,252.63901 C317.903331,252.081752 306.575528,256.963886 303.565869,264.667352 C304.885987,278.101105 313.275915,314.72061 343.621121,347.570743 L343.890748,347.590019 C346.816367,350.239619 350.636656,351.699352 354.709062,351.33661 C357.233744,351.110552 359.558833,350.206324 361.56177,348.811429 L362.050249,348.844724 C364.720249,350.700495 367.929502,351.671314 371.320839,351.369905 C372.616446,351.254248 373.852525,350.942324 375.027325,350.497219 C377.933685,356.51139 384.41522,360.398171 391.649607,359.760305 C397.25223,359.266133 402.014459,356.164419 404.81402,351.799238 L405.720944,351.862324 C408.517003,354.636343 412.233993,356.252038 416.24337,356.131124 C419.557672,361.149943 425.645272,364.237638 432.363167,363.647086 C434.893102,363.424533 437.254957,362.692038 439.361193,361.582781 C442.875089,365.90941 448.642289,368.481905 454.969751,367.924648 C461.22543,367.376152 466.509403,363.904686 469.384249,359.104914 C472.208321,361.374248 475.746728,362.592152 479.503987,362.257448 C483.149193,361.931505 486.38821,360.208914 488.821849,357.603124 L489.228039,357.631162 C489.781298,356.828571 490.25402,356.006705 490.693475,355.177829 C490.70398,355.162057 490.712734,355.144533 490.721489,355.128762 C490.781016,355.018362 490.854551,354.909714 490.910577,354.797562"/>
<path fill="#04A53C" d="M281.745403,61.5471238 L43.8020066,85.0272762 C50.8770689,71.2921143 61.4905377,58.1685333 71.7380852,51.4148571 L302.004138,21.2896762 C292.894623,32.030019 286.539148,46.6080762 281.745403,61.5471238 Z M302.004138,331.0528 L71.7380852,300.927619 C61.4905377,294.175695 50.8770689,281.050362 43.8020066,267.316952 L281.745403,290.797105 C286.539148,305.7344 292.894623,320.31421 302.004138,331.0528 Z M33.8766098,243.950705 C26.3585902,221.21181 24.03,207.9936 22.1723803,189.066133 L266.684852,192.017143 C267.285384,212.923048 270.116459,239.981562 275.03101,263.034133 L33.8766098,243.950705 Z M33.8766098,108.391771 L275.03101,89.3083429 C270.116459,112.360914 267.285384,139.419429 266.684852,160.327086 L22.1723803,163.276343 C24.03,144.350629 26.3585902,131.130667 33.8766098,108.391771 Z M378.597246,25.7126857 C363.342354,7.93478095 352.390977,-0.411809524 343.010085,0.672914286 C341.261016,0.895466667 76.1168852,37.8952381 76.1168852,37.8952381 C34.0429377,42.1780571 0.416695082,103.32739 0,176.172114 C0.416695082,249.015086 34.0429377,310.164419 76.1168852,314.44899 C76.1168852,314.44899 341.758249,351.583695 343.010085,351.669562 C345.228374,351.655543 347.418649,351.357638 349.57741,350.803886 C347.476426,350.178286 345.538269,349.083048 343.890748,347.590019 L343.621121,347.570743 C313.275915,314.722362 304.885987,278.101105 303.565869,264.667352 C303.56937,264.656838 303.576374,264.648076 303.579875,264.637562 C303.576374,264.648076 303.56937,264.656838 303.565869,264.667352 C294.696216,207.778057 296.161652,177.715962 296.161652,177.715962 C298.061292,95.2962286 322.033515,29.0001524 349.70522,29.638019 C377.375174,30.2776381 398.267705,97.6111238 396.368066,180.030857 C395.814807,203.999924 393.384669,226.586362 389.550374,246.577524 C393.489718,242.226362 398.773692,240.393371 403.68299,240.740343 C404.586413,240.805181 405.465325,240.957638 406.326728,241.155657 C423.131095,149.125867 405.514348,59.262019 378.597246,25.7126857 Z"/>
</g>
</svg>
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 560"><g fill="none"><path fill="#FA320A" d="M478.29 296.976c-3.99-63.966-36.52-111.823-85.468-138.579.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.265 6.78 108.735 108.63 170.89 211.193 164.49 102.556-6.395 193.466-80.572 186.683-189.307"/><path fill="#00912D" d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023"/></g></svg>
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 560"><path fill="#0AC855" d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878"/></svg>
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" version="1.1" viewBox="0 0 575 289.83"><defs><path id="d1pwhf9wy2" d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"/><path id="g5jjnq26yS" d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"/><path id="i3Prh1JpXt" d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"/><path id="a4ov9rRGQm" d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"/><path id="fk968BpsX" d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"/></defs><g><g><g><use fill="#f6c700" fill-opacity="1" opacity="1" xlink:href="#d1pwhf9wy2"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#d1pwhf9wy2"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#g5jjnq26yS"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#g5jjnq26yS"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#i3Prh1JpXt"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#i3Prh1JpXt"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#a4ov9rRGQm"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#a4ov9rRGQm"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#fk968BpsX"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#fk968BpsX"/></g></g></g></g></svg>
<svg version="1.1" viewBox="0 0 575 289.83" xmlns="http://www.w3.org/2000/svg"><path fill="#f6c700" d="m575 24.91c-1.56-12.76-11.03-22.93-23.09-24.91h-528.59c-13.21 2.17-23.32 14.16-23.32 28.61v232.25c0 16 12.37 28.97 27.64 28.97h519.95c14.06 0 25.67-11.01 27.41-25.26v-239.66z"/><path stroke="#000" d="m69.35 58.24h45.63v175.65h-45.63v-175.65z"/><path stroke="#000" d="m201.2 139.15c-3.92-26.77-6.1-41.65-6.53-44.62-1.91-14.33-3.73-26.8-5.47-37.44h-59.16v175.65h39.97l0.14-115.98 16.82 115.98h28.47l15.95-118.56 0.15 118.56h39.84v-175.65h-59.61l-10.57 82.06z"/><path stroke="#000" d="m346.71 93.63c0.5 2.24 0.76 7.32 0.76 15.26v68.1c0 11.69-0.76 18.85-2.27 21.49-1.52 2.64-5.56 3.95-12.11 3.95v-115.3c4.97 0 8.36 0.53 10.16 1.57 1.8 1.05 2.96 2.69 3.46 4.93zm20.61 137.32c5.43-1.19 9.99-3.29 13.69-6.28 3.69-3 6.28-7.15 7.76-12.46 1.49-5.3 2.37-15.83 2.37-31.58v-61.68c0-16.62-0.65-27.76-1.66-33.42-1.02-5.67-3.55-10.82-7.6-15.44-4.06-4.62-9.98-7.94-17.76-9.96-7.79-2.02-20.49-3.04-42.58-3.04h-34.04v175.65h55.28c12.74-0.4 20.92-0.99 24.54-1.79z"/><path stroke="#000" d="m464.76 204.7c-0.84 2.23-4.52 3.36-7.3 3.36-2.72 0-4.53-1.08-5.45-3.25-0.92-2.16-1.37-7.09-1.37-14.81v-46.42c0-8 0.4-12.99 1.21-14.98 0.8-1.97 2.56-2.97 5.28-2.97 2.78 0 6.51 1.13 7.47 3.4 0.95 2.27 1.43 7.12 1.43 14.55v45.01c-0.29 9.25-0.71 14.62-1.27 16.11zm-58.08 26.51h41.08c1.71-6.71 2.65-10.44 2.84-11.19 3.72 4.5 7.81 7.88 12.3 10.12 4.47 2.25 11.16 3.37 16.34 3.37 7.21 0 13.43-1.89 18.68-5.68 5.24-3.78 8.58-8.26 10-13.41 1.42-5.16 2.13-13 2.13-23.54v-49.28c0-10.6-0.24-17.52-0.71-20.77s-1.87-6.56-4.2-9.95-5.72-6.02-10.16-7.9-9.68-2.82-15.72-2.82c-5.25 0-11.97 1.05-16.45 3.12-4.47 2.07-8.53 5.21-12.17 9.42v-57.14h-43.96v175.65z"/></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 320.03 103.61" style="&#10;"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#radial-gradient);}.cls-3{fill:#e5a00d;}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset="0.17" stop-color="#f9be03"/><stop offset="0.51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" class="cls-1" points="320.03 -0.09 289.96 -0.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -0.09"/><g id="chevron"><polygon class="cls-2" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/><polygon class="cls-3" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/></g><path id="E" class="cls-1" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z"/><path id="L" class="cls-1" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z"/><path id="P" class="cls-1" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg"><path transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)" d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)" d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></svg>

After

Width:  |  Height:  |  Size: 737 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 216.7 216.9" xmlns="http://www.w3.org/2000/svg"><path d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" clip-rule="evenodd" fill="#EEE" fill-rule="evenodd"/><path d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" clip-rule="evenodd" fill="#3A3F51" fill-rule="evenodd"/><g clip-rule="evenodd"><path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/><path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/><path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient)}</style><linearGradient id="linear-gradient" x2="185.04" y1="66.7" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z" class="cls-1"/></g></g></svg>
<svg viewBox="0 0 185.04 133.4" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:url(#a)}</style><linearGradient id="a" x2="185.04" y1="66.7" y2="66.7" gradientUnits="userSpaceOnUse"><stop stop-color="#90cea1" offset="0"/><stop stop-color="#3cbec9" offset=".56"/><stop stop-color="#00b3e5" offset="1"/></linearGradient></defs><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 49.994049 27.764576" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><g transform="translate(-80.836 -134.28)" stroke-width=".26458"><ellipse cx="104.39" cy="140.67" rx="3.0402" ry="2.5725" fill="#fff"/><path transform="translate(-8.3333e-8)" d="m85.973 162.05c-0.2783-9e-3 -0.76695-0.12188-1.0859-0.25035-0.31894-0.12847-0.75831-0.50788-0.97638-0.84312-0.27168-0.41768-0.81789-4.2912-1.7352-12.306-0.7363-6.4328-1.3387-11.951-1.3387-12.263 0-0.31189 0.36978-0.9125 0.82173-1.3347 0.55888-0.52208 1.0976-0.76762 1.6843-0.76762 0.4744 0 5.7899 0.65473 11.812 1.4549 6.0223 0.80022 11.395 1.5787 11.94 1.73 0.67007 0.18601 1.1196 0.52432 1.3891 1.0455 0.23816 0.46053 0.39842 1.4812 0.39842 2.5375 0 1.6585-0.0587 1.832-0.95642 2.8256-0.52604 0.58226-1.2554 1.7118-1.6209 2.51-0.56065 1.2246-0.6448 1.7259-0.5387 3.2089 0.0692 0.96664 0.33525 2.1733 0.59131 2.6815 0.25605 0.50816 0.9453 1.4214 1.5317 2.0295 1.0049 1.0421 1.0581 1.1768 0.92626 2.3462-0.0925 0.82058-0.35123 1.4496-0.76415 1.8577-0.57063 0.56398-1.5248 0.74328-11.099 2.0855-5.7608 0.80767-10.702 1.461-10.98 1.4517zm5.8777-7.1419c0.64371 9e-3 1.2219-0.14144 1.4082-0.36594 0.17385-0.20948 0.27773-0.71409 0.23083-1.1214-0.07317-0.63544-0.23231-0.76418-1.122-0.90763-0.5702-0.0919-1.1992-0.38942-1.3979-0.66108-0.25954-0.35495-0.34007-1.2022-0.2862-3.011l0.07497-2.517h3.7011l1.7217 4.2333 1.7217 4.2333 1.4787 0.0781c0.97936 0.0517 1.5672-0.0376 1.7409-0.26458 0.14423-0.18847 1.1888-2.5136 2.3212-5.1671 1.1324-2.6534 1.9897-5.0049 1.905-5.2255-0.0964-0.25118-0.49511-0.40113-1.0666-0.40113-0.50198 0-1.0918 0.0687-1.3108 0.15275-0.21893 0.084-1.0155 1.6914-1.7701 3.5719-0.75462 1.8805-1.4697 3.4191-1.589 3.4191-0.1193 0-0.45209-0.62507-0.73953-1.3891-0.28745-0.76398-0.8376-2.2224-1.2226-3.2408s-0.81858-1.971-0.9636-2.1167-1.5382-0.32442-3.0959-0.39718l-2.8323-0.13229-0.13229-1.8521-0.13229-1.8521h-2.6458l-0.13229 1.8365c-0.13098 1.8183-0.14096 1.8388-1.0075 2.062-0.81451 0.20989-0.86954 0.29343-0.79375 1.205 0.07128 0.85741 0.18035 0.99633 0.87518 1.1147l0.79375 0.13524 0.13229 3.2208c0.11196 2.726 0.2174 3.3355 0.68624 3.967 0.30467 0.4104 0.95952 0.88829 1.4552 1.062s1.3927 0.32252 1.9933 0.33073zm12.666-11.891c0.45112 0 0.96309-0.14287 1.1377-0.3175 0.17463-0.17462 0.3175-0.64237 0.3175-1.0394 0-0.39707-0.1871-0.90903-0.41577-1.1377-0.22868-0.22868-0.69642-0.41577-1.0394-0.41577-0.34301 0-0.81075 0.18709-1.0394 0.41577s-0.41577 0.74064-0.41577 1.1377c0 0.39706 0.14287 0.86481 0.3175 1.0394 0.17462 0.17463 0.68659 0.3175 1.1377 0.3175z" fill="#1b7d3d"/><path transform="translate(-8.3333e-8)" d="m114.46 154.52c-3.0274 0.0544-3.7379-0.0118-4.6609-0.43422-0.59897-0.27412-1.3995-0.90545-1.779-1.403-0.37946-0.49751-0.83257-1.5225-1.0069-2.2778-0.24624-1.0668-0.24624-1.6796 0-2.7464 0.17434-0.75526 0.61766-1.7674 0.98516-2.2492 0.3675-0.48183 1.049-1.0738 1.5144-1.3155 0.48523-0.25197 1.8056-0.5058 3.0952-0.59503l2.249-0.15561 0.13229-1.8521 0.1323-1.8521h2.9104v14.817zm-0.92604-2.3736 1.3229-0.0718v-5.8208l-1.6062-0.0775c-1.4422-0.0696-1.6785-5e-3 -2.3151 0.6314-0.38991 0.38991-0.72779 1.0201-0.75085 1.4004-0.023 0.38034-0.0238 0.92965-2e-3 1.2207 0.0222 0.29104 0.10377 0.80075 0.18131 1.1327 0.0775 0.33193 0.52474 0.8405 0.99377 1.1302 0.615 0.37978 1.2217 0.50661 2.1757 0.45481zm9.7151 2.4359c-2.5936 4e-3 -3.5477-0.0819-3.6425-0.32887-0.0706-0.18407-0.0967-3.5476-0.0578-7.4745l0.0706-7.1398h2.9104l0.13229 1.8521 0.13229 1.8521 2.2687 0.15541c1.6342 0.11195 2.5591 0.31887 3.3073 0.73989 0.57124 0.32147 1.3303 1.0254 1.6868 1.5644 0.45872 0.69347 0.67992 1.4556 0.75677 2.6074 0.0597 0.89511-0.0338 2.059-0.20789 2.5864-0.17406 0.52742-0.62874 1.33-1.0104 1.7836-0.38165 0.45358-1.1751 1.0432-1.7632 1.3104-0.86286 0.39194-1.7476 0.48683-4.5833 0.49152zm0.84267-2.3754c0.64994 0 1.4871-0.1161 1.8603-0.25799 0.3732-0.14189 0.92337-0.64791 1.2226-1.1245 0.41645-0.66331 0.5175-1.173 0.43097-2.1738-0.0877-1.0148-0.27505-1.4467-0.83705-1.9301-0.61948-0.53286-0.95861-0.6115-2.3491-0.54474l-1.6251 0.078-0.0763 2.627c-0.042 1.4448-0.0159 2.7843 0.0578 2.9766 0.0835 0.21749 0.5806 0.34956 1.3158 0.34956z" fill="#fff"/></g></svg>
<svg version="1.1" viewBox="0 0 49.994 27.765" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-80.836 -134.28)" stroke-width=".265"><ellipse cx="104.39" cy="140.67" rx="3.04" ry="2.572" fill="#fff"/><path transform="translate(-8.3333e-8)" d="m85.973 162.05c-0.2783-9e-3 -0.76695-0.12188-1.0859-0.25035-0.31894-0.12847-0.75831-0.50788-0.97638-0.84312-0.27168-0.41768-0.81789-4.2912-1.7352-12.306-0.7363-6.4328-1.3387-11.951-1.3387-12.263 0-0.31189 0.36978-0.9125 0.82173-1.3347 0.55888-0.52208 1.0976-0.76762 1.6843-0.76762 0.4744 0 5.7899 0.65473 11.812 1.4549 6.0223 0.80022 11.395 1.5787 11.94 1.73 0.67007 0.18601 1.1196 0.52432 1.3891 1.0455 0.23816 0.46053 0.39842 1.4812 0.39842 2.5375 0 1.6585-0.0587 1.832-0.95642 2.8256-0.52604 0.58226-1.2554 1.7118-1.6209 2.51-0.56065 1.2246-0.6448 1.7259-0.5387 3.2089 0.0692 0.96664 0.33525 2.1733 0.59131 2.6815 0.25605 0.50816 0.9453 1.4214 1.5317 2.0295 1.0049 1.0421 1.0581 1.1768 0.92626 2.3462-0.0925 0.82058-0.35123 1.4496-0.76415 1.8577-0.57063 0.56398-1.5248 0.74328-11.099 2.0855-5.7608 0.80767-10.702 1.461-10.98 1.4517zm5.8777-7.1419c0.64371 9e-3 1.2219-0.14144 1.4082-0.36594 0.17385-0.20948 0.27773-0.71409 0.23083-1.1214-0.07317-0.63544-0.23231-0.76418-1.122-0.90763-0.5702-0.0919-1.1992-0.38942-1.3979-0.66108-0.25954-0.35495-0.34007-1.2022-0.2862-3.011l0.07497-2.517h3.7011l1.7217 4.2333 1.7217 4.2333 1.4787 0.0781c0.97936 0.0517 1.5672-0.0376 1.7409-0.26458 0.14423-0.18847 1.1888-2.5136 2.3212-5.1671 1.1324-2.6534 1.9897-5.0049 1.905-5.2255-0.0964-0.25118-0.49511-0.40113-1.0666-0.40113-0.50198 0-1.0918 0.0687-1.3108 0.15275-0.21893 0.084-1.0155 1.6914-1.7701 3.5719-0.75462 1.8805-1.4697 3.4191-1.589 3.4191s-0.45209-0.62507-0.73953-1.3891c-0.28745-0.76398-0.8376-2.2224-1.2226-3.2408s-0.81858-1.971-0.9636-2.1167-1.5382-0.32442-3.0959-0.39718l-2.8323-0.13229-0.13229-1.8521-0.13229-1.8521h-2.6458l-0.13229 1.8365c-0.13098 1.8183-0.14096 1.8388-1.0075 2.062-0.81451 0.20989-0.86954 0.29343-0.79375 1.205 0.07128 0.85741 0.18035 0.99633 0.87518 1.1147l0.79375 0.13524 0.13229 3.2208c0.11196 2.726 0.2174 3.3355 0.68624 3.967 0.30467 0.4104 0.95952 0.88829 1.4552 1.062s1.3927 0.32252 1.9933 0.33073zm12.666-11.891c0.45112 0 0.96309-0.14287 1.1377-0.3175 0.17463-0.17462 0.3175-0.64237 0.3175-1.0394 0-0.39707-0.1871-0.90903-0.41577-1.1377-0.22868-0.22868-0.69642-0.41577-1.0394-0.41577-0.34301 0-0.81075 0.18709-1.0394 0.41577s-0.41577 0.74064-0.41577 1.1377 0.14287 0.86481 0.3175 1.0394c0.17462 0.17463 0.68659 0.3175 1.1377 0.3175z" fill="#1b7d3d"/><path transform="translate(-8.3333e-8)" d="m114.46 154.52c-3.0274 0.0544-3.7379-0.0118-4.6609-0.43422-0.59897-0.27412-1.3995-0.90545-1.779-1.403-0.37946-0.49751-0.83257-1.5225-1.0069-2.2778-0.24624-1.0668-0.24624-1.6796 0-2.7464 0.17434-0.75526 0.61766-1.7674 0.98516-2.2492 0.3675-0.48183 1.049-1.0738 1.5144-1.3155 0.48523-0.25197 1.8056-0.5058 3.0952-0.59503l2.249-0.15561 0.13229-1.8521 0.1323-1.8521h2.9104v14.817zm-0.92604-2.3736 1.3229-0.0718v-5.8208l-1.6062-0.0775c-1.4422-0.0696-1.6785-5e-3 -2.3151 0.6314-0.38991 0.38991-0.72779 1.0201-0.75085 1.4004-0.023 0.38034-0.0238 0.92965-2e-3 1.2207 0.0222 0.29104 0.10377 0.80075 0.18131 1.1327 0.0775 0.33193 0.52474 0.8405 0.99377 1.1302 0.615 0.37978 1.2217 0.50661 2.1757 0.45481zm9.7151 2.4359c-2.5936 4e-3 -3.5477-0.0819-3.6425-0.32887-0.0706-0.18407-0.0967-3.5476-0.0578-7.4745l0.0706-7.1398h2.9104l0.13229 1.8521 0.13229 1.8521 2.2687 0.15541c1.6342 0.11195 2.5591 0.31887 3.3073 0.73989 0.57124 0.32147 1.3303 1.0254 1.6868 1.5644 0.45872 0.69347 0.67992 1.4556 0.75677 2.6074 0.0597 0.89511-0.0338 2.059-0.20789 2.5864-0.17406 0.52742-0.62874 1.33-1.0104 1.7836-0.38165 0.45358-1.1751 1.0432-1.7632 1.3104-0.86286 0.39194-1.7476 0.48683-4.5833 0.49152zm0.84267-2.3754c0.64994 0 1.4871-0.1161 1.8603-0.25799s0.92337-0.64791 1.2226-1.1245c0.41645-0.66331 0.5175-1.173 0.43097-2.1738-0.0877-1.0148-0.27505-1.4467-0.83705-1.9301-0.61948-0.53286-0.95861-0.6115-2.3491-0.54474l-1.6251 0.078-0.0763 2.627c-0.042 1.4448-0.0159 2.7843 0.0578 2.9766 0.0835 0.21749 0.5806 0.34956 1.3158 0.34956z" fill="#fff"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,21 +1 @@
<svg
viewBox="-2 -2 42 42"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<g fill="none" fillRule="evenodd">
<g transform="translate(1 1)" stroke-width="6">
<circle stroke-opacity=".5" cx="18" cy="18" r="18" />
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</g>
</svg>
<svg stroke="currentColor" viewBox="-2 -2 42 42" xmlns="http://www.w3.org/2000/svg"><g transform="translate(1 1)" stroke-width="6" fill="none"><circle cx="18" cy="18" r="18" stroke-opacity=".5"/><path d="m36 18c0-9.94-8.06-18-18-18"><animateTransform attributeName="transform" dur="1s" from="0 18 18" repeatCount="indefinite" to="360 18 18" type="rotate"/></path></g></svg>

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><g data-name="Layer 2"><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)" data-name="Layer 1"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#667EEA" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#fff" fill-rule="evenodd" d="M31 20.2C32.9096 20.2 34.7409 19.4414 36.0912 18.0912C37.4414 16.7409 38.2 14.9095 38.2 13C38.2 11.0904 37.4414 9.25908 36.0912 7.90882C34.7409 6.55856 32.9096 5.79999 31 5.79999C29.0904 5.79999 27.2591 6.55856 25.9088 7.90882C24.5586 9.25908 23.8 11.0904 23.8 13C23.8 14.9095 24.5586 16.7409 25.9088 18.0912C27.2591 19.4414 29.0904 20.2 31 20.2ZM31.9 9.39999C31.9 9.16129 31.8052 8.93237 31.6364 8.76359C31.4676 8.59481 31.2387 8.49999 31 8.49999C30.7613 8.49999 30.5324 8.59481 30.3636 8.76359C30.1948 8.93237 30.1 9.16129 30.1 9.39999V13C30.1 13.2387 30.1949 13.4675 30.3637 13.6363L32.9089 16.1824C32.9925 16.266 33.0918 16.3323 33.201 16.3776C33.3103 16.4228 33.4274 16.4461 33.5456 16.4461C33.6639 16.4461 33.781 16.4228 33.8903 16.3776C33.9995 16.3323 34.0988 16.266 34.1824 16.1824C34.266 16.0988 34.3323 15.9995 34.3776 15.8902C34.4229 15.781 34.4461 15.6639 34.4461 15.5456C34.4461 15.4274 34.4229 15.3103 34.3776 15.201C34.3323 15.0918 34.266 14.9925 34.1824 14.9089L31.9 12.6274V9.39999Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor" class="w-6 h-6" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>

Before

Width:  |  Height:  |  Size: 284 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" class="w-6 h-6" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>

Before

Width:  |  Height:  |  Size: 260 B

View File

@@ -4,7 +4,6 @@ import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
dockerVolumeMissing: 'Docker Volume Mount Missing',
dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});
@@ -26,14 +25,14 @@ const AppDataWarning: React.FC = () => {
return (
<>
{!data.appData && (
<Alert title={intl.formatMessage(messages.dockerVolumeMissing)}>
{intl.formatMessage(messages.dockerVolumeMissingDescription, {
<Alert
title={intl.formatMessage(messages.dockerVolumeMissingDescription, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
appDataPath: data.appDataPath,
})}
</Alert>
/>
)}
</>
);

View File

@@ -1,40 +1,39 @@
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
import axios from 'axios';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection';
import { LanguageContext } from '../../context/LanguageContext';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import StatusBadge from '../StatusBadge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import PageTitle from '../Common/PageTitle';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
import PageTitle from '../Common/PageTitle';
import { useUser, Permission } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
overview: 'Overview',
movies: 'Movies',
numberofmovies: 'Number of Movies: {count}',
requesting: 'Requesting…',
request: 'Request',
numberofmovies: '{count} Movies',
requestcollection: 'Request Collection',
requestswillbecreated:
'The following titles will have requests created for them:',
request4k: 'Request 4K',
requestcollection4k: 'Request Collection in 4K',
requestswillbecreated4k:
'The following titles will have 4K requests created for them:',
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestSuccess: '<strong>{title}</strong> requested successfully!',
});
interface CollectionDetailsProps {
@@ -48,20 +47,23 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
const router = useRouter();
const settings = useSettings();
const { addToast } = useToasts();
const { locale } = useContext(LanguageContext);
const { hasPermission } = useUser();
const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false);
const [is4k, setIs4k] = useState(false);
const { data, error, revalidate } = useSWR<Collection>(
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
`/api/v1/collection/${router.query.collectionId}`,
{
initialData: collection,
revalidateOnMount: true,
}
);
const { data: genres } = useSWR<{ id: number; name: string }[]>(
`/api/v1/genres/movie`
);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -105,6 +107,24 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
}
const hasRequestable =
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) &&
data.parts.filter(
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
).length > 0;
const hasRequestable4k =
settings.currentSettings.movie4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
type: 'or',
}) &&
data.parts.filter(
(part) =>
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
).length > 0;
const requestableParts = data.parts.filter(
(part) =>
!part.mediaInfo ||
@@ -147,14 +167,68 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
}
};
const collectionAttributes: React.ReactNode[] = [];
collectionAttributes.push(
intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})
);
if (genres && data.parts.some((part) => part.genreIds.length)) {
collectionAttributes.push(
uniq(
data.parts.reduce(
(genresList: number[], curr) => genresList.concat(curr.genreIds),
[]
)
)
.map((genreId) => (
<Link
href={`/discover/movies/genre/${genreId}`}
key={`genre-${genreId}`}
>
<a className="hover:underline">
{genres.find((g) => g.id === genreId)?.name}
</a>
</Link>
))
.reduce((prev, curr) => (
<>
{intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})}
</>
))
);
}
return (
<div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
className="media-page"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}}
>
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.name} />
<Transition
enter="opacity-0 transition duration-300"
@@ -169,8 +243,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
onOk={() => requestBundle()}
okText={
isRequesting
? intl.formatMessage(messages.requesting)
: intl.formatMessage(is4k ? messages.request4k : messages.request)
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(
is4k ? globalMessages.request4k : globalMessages.request
)
}
okDisabled={isRequesting}
okButtonType="primary"
@@ -178,22 +254,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
title={intl.formatMessage(
is4k ? messages.requestcollection4k : messages.requestcollection
)}
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
}
iconSvg={<DuplicateIcon />}
>
<p>
{intl.formatMessage(
@@ -216,24 +277,29 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</ul>
</Modal>
</Transition>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
<div className="media-header">
<div className="media-poster">
<CachedImage
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
<span className="ml-2 lg:ml-0">
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
</span>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -241,123 +307,79 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
type: 'or',
}
) && (
<span>
<StatusBadge
status={collectionStatus4k}
is4k
inProgress={data.parts.some(
(part) =>
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
)}
/>
</span>
<StatusBadge
status={collectionStatus4k}
is4k
inProgress={data.parts.some(
(part) =>
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
)}
/>
)}
</div>
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0">
{intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})}
<h1>{data.name}</h1>
<span className="media-attributes">
{collectionAttributes.length > 0 &&
collectionAttributes
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
</>
))}
</span>
</div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
{hasPermission(Permission.REQUEST) &&
(collectionStatus !== MediaStatus.AVAILABLE ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus4k !== MediaStatus.AVAILABLE)) && (
<div className="mb-3 sm:mb-0">
<ButtonWithDropdown
<div className="media-actions">
{(hasRequestable || hasRequestable4k) && (
<ButtonWithDropdown
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(!hasRequestable);
}}
text={
<>
<DownloadIcon />
<span>
{intl.formatMessage(
hasRequestable
? messages.requestcollection
: messages.requestcollection4k
)}
</span>
</>
}
>
{hasRequestable && hasRequestable4k && (
<ButtonWithDropdown.Item
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(collectionStatus === MediaStatus.AVAILABLE);
setIs4k(true);
}}
text={
<>
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(
collectionStatus === MediaStatus.AVAILABLE
? messages.requestcollection4k
: messages.requestcollection
)}
</span>
</>
}
>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus !== MediaStatus.AVAILABLE &&
collectionStatus4k !== MediaStatus.AVAILABLE && (
<ButtonWithDropdown.Item
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(true);
}}
>
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
</div>
)}
<DownloadIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
</div>
</div>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.movies)}</span>
{data.overview && (
<div className="media-overview">
<div className="flex-1">
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>{data.overview}</p>
</div>
</div>
)}
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(globalMessages.movies)}</span>
</div>
</div>
<Slider
sliderKey="collection-movies"

View File

@@ -1,90 +1,57 @@
import {
ExclamationIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/solid';
import React from 'react';
interface AlertProps {
title: string;
title?: React.ReactNode;
type?: 'warning' | 'info' | 'error';
}
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
let design = {
bgColor: 'bg-yellow-600',
titleColor: 'text-yellow-200',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: (
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
svg: <ExclamationIcon className="w-5 h-5" />,
};
switch (type) {
case 'info':
design = {
bgColor: 'bg-indigo-600',
titleColor: 'text-indigo-200',
titleColor: 'text-indigo-100',
textColor: 'text-indigo-300',
svg: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
svg: <InformationCircleIcon className="w-5 h-5" />,
};
break;
case 'error':
design = {
bgColor: 'bg-red-600',
titleColor: 'text-red-200',
titleColor: 'text-red-100',
textColor: 'text-red-300',
svg: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
svg: <XCircleIcon className="w-5 h-5" />,
};
break;
}
return (
<div className={`rounded-md p-4 mb-5 ${design.bgColor}`}>
<div className={`rounded-md p-4 mb-4 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
{title && (
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
)}
{children && (
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
{children}
</div>
)}
</div>
</div>
</div>

View File

@@ -45,52 +45,50 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer',
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
buttonStyle.push(
'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50'
'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700'
);
break;
case 'danger':
buttonStyle.push(
'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50'
'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
);
break;
case 'warning':
buttonStyle.push(
'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50'
'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700'
);
break;
case 'success':
buttonStyle.push(
'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50'
'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700'
);
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50'
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:
buttonStyle.push(
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
'text-gray-200 bg-gray-600 border-gray-600 hover:text-white hover:bg-gray-500 hover:border-gray-500 group-hover:text-white group-hover:bg-gray-500 group-hover:border-gray-500 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-500 active:border-gray-500'
);
}
switch (buttonSize) {
case 'sm':
buttonStyle.push('px-2.5 py-1.5 text-xs');
break;
case 'md':
buttonStyle.push('px-4 py-2 text-sm');
buttonStyle.push('px-2.5 py-1.5 text-xs button-sm');
break;
case 'lg':
buttonStyle.push('px-6 py-3 text-base');
buttonStyle.push('px-6 py-3 text-base button-lg');
break;
case 'md':
default:
buttonStyle.push('px-4 py-2 text-sm');
buttonStyle.push('px-4 py-2 text-sm button-md');
}
buttonStyle.push(className ?? '');

View File

@@ -1,13 +1,14 @@
import { ChevronDownIcon } from '@heroicons/react/solid';
import React, {
useState,
useRef,
AnchorHTMLAttributes,
ReactNode,
ButtonHTMLAttributes,
ReactNode,
useRef,
useState,
} from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
import Transition from '../../Transition';
import { withProperties } from '../../../utils/typeHelpers';
import Transition from '../../Transition';
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
buttonType?: 'primary' | 'ghost';
@@ -18,16 +19,16 @@ const DropdownItem: React.FC<DropdownItemProps> = ({
buttonType = 'primary',
...props
}) => {
let styleClass = '';
let styleClass = 'button-md text-white';
switch (buttonType) {
case 'ghost':
styleClass =
'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white';
styleClass +=
' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white';
break;
default:
styleClass =
'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white';
styleClass +=
' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
}
return (
<a
@@ -59,29 +60,28 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
mainButtonClasses: '',
dropdownSideButtonClasses: '',
dropdownClasses: '',
mainButtonClasses: 'button-md text-white border',
dropdownSideButtonClasses: 'button-md border',
dropdownClasses: 'button-md',
};
switch (buttonType) {
case 'ghost':
styleClasses.mainButtonClasses =
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses =
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownClasses = 'bg-gray-700';
styleClasses.mainButtonClasses +=
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
styleClasses.dropdownClasses += ' bg-gray-700';
break;
default:
styleClasses.mainButtonClasses =
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses =
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownClasses = 'bg-indigo-600';
styleClasses.mainButtonClasses +=
' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses +=
' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownClasses += ' bg-indigo-600';
}
return (
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
<span className="relative inline-flex h-full rounded-md shadow-sm">
<button
type="button"
className={`relative inline-flex h-full items-center px-4 py-2 text-sm leading-5 font-medium z-10 hover:z-20 focus:z-20 focus:outline-none transition ease-in-out duration-150 ${
@@ -93,29 +93,14 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
{text}
</button>
{children && (
<span className="relative z-10 block -ml-px">
<span className="relative block -ml-px">
<button
type="button"
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md focus:z-10 ${styleClasses.dropdownSideButtonClasses}`}
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md z-10 hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
{dropdownIcon ? (
dropdownIcon
) : (
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)}
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
</button>
<Transition
show={isOpen}
@@ -126,7 +111,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="absolute right-0 z-40 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>

View File

@@ -0,0 +1,18 @@
import Image, { ImageProps } from 'next/image';
import React from 'react';
import useSettings from '../../../hooks/useSettings';
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/
const CachedImage: React.FC<ImageProps> = (props) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
};
export default CachedImage;

View File

@@ -1,14 +1,16 @@
import React, {
useState,
useEffect,
HTMLAttributes,
ForwardRefRenderFunction,
HTMLAttributes,
useEffect,
useState,
} from 'react';
import CachedImage from '../CachedImage';
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
backgroundImages: string[];
rotationSpeed?: number;
isDarker?: boolean;
forceOptimize?: boolean;
}
const DEFAULT_ROTATION_SPEED = 6000;
@@ -18,6 +20,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
backgroundImages,
rotationSpeed = DEFAULT_ROTATION_SPEED,
isDarker,
forceOptimize,
...props
},
ref
@@ -43,19 +46,37 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
}
let overrides = {};
if (forceOptimize) {
overrides = {
unoptimized: false,
};
}
return (
<div ref={ref}>
{backgroundImages.map((imageUrl, i) => (
<div
key={`banner-image-${i}`}
className={`absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
className={`absolute absolute-top-shift inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
i === activeIndex ? 'opacity-100' : 'opacity-0'
}`}
style={{
backgroundImage: `${gradient}, url(${imageUrl})`,
}}
{...props}
/>
>
<CachedImage
className="absolute inset-0 w-full h-full"
alt=""
src={imageUrl}
layout="fill"
objectFit="cover"
{...overrides}
/>
<div
className="absolute inset-0"
style={{ backgroundImage: gradient }}
/>
</div>
))}
</div>
);

View File

@@ -3,15 +3,16 @@ import { withProperties } from '../../../utils/typeHelpers';
interface ListItemProps {
title: string;
className?: string;
}
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
return (
<div>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="block text-sm font-medium text-gray-400">{title}</dt>
<dt className="block text-sm font-bold text-gray-400">{title}</dt>
<dd className="flex text-sm text-white sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>
<span className={`flex-grow ${className}`}>{children}</span>
</dd>
</div>
</div>

View File

@@ -1,17 +1,14 @@
import React from 'react';
import { useIntl } from 'react-intl';
import {
TvResult,
MovieResult,
PersonResult,
TvResult,
} from '../../../../server/models/Search';
import TitleCard from '../../TitleCard';
import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
noresults: 'No results.',
});
import TitleCard from '../../TitleCard';
interface ListViewProps {
items?: (TvResult | MovieResult | PersonResult)[];
@@ -34,11 +31,11 @@ const ListView: React.FC<ListViewProps> = ({
<>
{isEmpty && (
<div className="w-full mt-64 text-2xl text-center text-gray-400">
{intl.formatMessage(messages.noresults)}
{intl.formatMessage(globalMessages.noresults)}
</div>
)}
<ul className="cardList">
{items?.map((title) => {
<ul className="cards-vertical">
{items?.map((title, index) => {
let titleCard: React.ReactNode;
switch (title.mediaType) {
@@ -90,7 +87,7 @@ const ListView: React.FC<ListViewProps> = ({
break;
}
return <li key={title.id}>{titleCard}</li>;
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{isLoading &&
!isReachingEnd &&

View File

@@ -1,12 +1,13 @@
import React, { MouseEvent, ReactNode, useRef } from 'react';
import ReactDOM from 'react-dom';
import Button, { ButtonType } from '../Button';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import LoadingSpinner from '../LoadingSpinner';
import useClickOutside from '../../../hooks/useClickOutside';
import { useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
import Button, { ButtonType } from '../Button';
import CachedImage from '../CachedImage';
import LoadingSpinner from '../LoadingSpinner';
interface ModalProps {
title?: string;
@@ -29,6 +30,7 @@ interface ModalProps {
backgroundClickable?: boolean;
iconSvg?: ReactNode;
loading?: boolean;
backdrop?: string;
}
const Modal: React.FC<ModalProps> = ({
@@ -53,6 +55,7 @@ const Modal: React.FC<ModalProps> = ({
tertiaryDisabled = false,
tertiaryText,
onTertiary,
backdrop,
}) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
@@ -66,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-50"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-70"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
@@ -98,18 +101,35 @@ const Modal: React.FC<ModalProps> = ({
show={!loading}
>
<div
className="inline-block w-full max-h-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
className="relative inline-block w-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl ring-1 ring-gray-500 sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
ref={modalRef}
style={{
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
}}
>
<div className="sm:flex sm:items-center">
{iconSvg && (
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto text-white bg-gray-600 rounded-full sm:mx-0 sm:h-10 sm:w-10">
{iconSvg}
</div>
)}
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
<CachedImage
alt=""
src={backdrop}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(55, 65, 81, 0.85) 0%, rgba(55, 65, 81, 1) 100%)',
}}
/>
</div>
)}
<div className="relative sm:flex sm:items-center">
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
@@ -117,7 +137,7 @@ const Modal: React.FC<ModalProps> = ({
>
{title && (
<span
className="text-lg font-medium leading-6 text-white"
className="text-lg font-bold leading-6 text-white"
id="modal-headline"
>
{title}
@@ -126,12 +146,12 @@ const Modal: React.FC<ModalProps> = ({
</div>
</div>
{children && (
<div className="mt-4 text-sm leading-5 text-gray-300">
<div className="relative mt-4 text-sm leading-5 text-gray-300">
{children}
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
<div className="relative flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import ButtonWithDropdown from '../ButtonWithDropdown';
interface PlayButtonProps {
@@ -8,6 +8,7 @@ interface PlayButtonProps {
export interface PlayButtonLink {
text: string;
url: string;
svg: ReactNode;
}
const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
@@ -20,26 +21,7 @@ const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
buttonType="ghost"
text={
<>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{links[0].svg}
<span>{links[0].text}</span>
</>
}
@@ -57,7 +39,8 @@ const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
}}
buttonType="ghost"
>
{link.text}
{link.svg}
<span>{link.text}</span>
</ButtonWithDropdown.Item>
);
})}

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useRef } from 'react';
interface ProgressCircleProps {
className?: string;
progress?: number;
useHeatLevel?: boolean;
}
const ProgressCircle: React.FC<ProgressCircleProps> = ({
className,
progress = 0,
useHeatLevel,
}) => {
const ref = useRef<SVGCircleElement>(null);
let color = '';
let emptyColor = 'text-gray-300';
if (useHeatLevel) {
color = 'text-green-500';
if (progress <= 50) {
color = 'text-yellow-500';
}
if (progress <= 10) {
color = 'text-red-500';
}
if (progress === 0) {
emptyColor = 'text-red-600';
}
}
useEffect(() => {
if (ref && ref.current) {
const radius = ref.current?.r.baseVal.value;
const circumference = (radius ?? 0) * 2 * Math.PI;
const offset = circumference - (progress / 100) * circumference;
ref.current.style.strokeDashoffset = `${offset}`;
ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
}
});
return (
<svg className={`${className} ${color}`} viewBox="0 0 24 24">
<circle
className={`${emptyColor} opacity-30`}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
<circle
style={{
transition: '0.35s stroke-dashoffset',
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
ref={ref}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
</svg>
);
};
export default ProgressCircle;

View File

@@ -0,0 +1,55 @@
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { Field } from 'formik';
import React, { useState } from 'react';
interface CustomInputProps extends React.ComponentProps<'input'> {
as?: 'input';
}
interface CustomFieldProps extends React.ComponentProps<typeof Field> {
as?: 'field';
}
type SensitiveInputProps = CustomInputProps | CustomFieldProps;
const SensitiveInput: React.FC<SensitiveInputProps> = ({
as = 'input',
...props
}) => {
const [isHidden, setHidden] = useState(true);
const Component = as === 'input' ? 'input' : Field;
const componentProps =
as === 'input'
? props
: {
...props,
as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined,
};
return (
<>
<Component
{...componentProps}
className={`rounded-l-only ${componentProps.className ?? ''}`}
type={
isHidden
? 'password'
: props.type !== 'password'
? props.type ?? 'text'
: 'text'
}
/>
<button
onClick={(e) => {
e.preventDefault();
setHidden(!isHidden);
}}
type="button"
className="input-action"
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</>
);
};
export default SensitiveInput;

View File

@@ -0,0 +1,171 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { hasPermission, Permission } from '../../../../server/lib/permissions';
import { useUser } from '../../../hooks/useUser';
export interface SettingsRoute {
text: string;
content?: React.ReactNode;
route: string;
regex: RegExp;
requiredPermission?: Permission | Permission[];
permissionType?: { type: 'and' | 'or' };
hidden?: boolean;
}
const SettingsLink: React.FC<{
tabType: 'default' | 'button';
currentPath: string;
route: string;
regex: RegExp;
hidden?: boolean;
isMobile?: boolean;
}> = ({
children,
tabType,
currentPath,
route,
regex,
hidden = false,
isMobile = false,
}) => {
if (hidden) {
return null;
}
if (isMobile) {
return <option value={route}>{children}</option>;
}
let linkClasses =
'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 border-transparent whitespace-nowrap first:ml-0';
let activeLinkColor = 'text-indigo-500 border-indigo-600';
let inactiveLinkColor =
'text-gray-500 border-transparent hover:text-gray-300 hover:border-gray-400 focus:text-gray-300 focus:border-gray-400';
if (tabType === 'button') {
linkClasses =
'px-3 py-2 text-sm font-medium transition duration-300 rounded-md whitespace-nowrap mx-2 my-1';
activeLinkColor = 'bg-indigo-700';
inactiveLinkColor = 'bg-gray-800 hover:bg-gray-700 focus:bg-gray-700';
}
return (
<Link href={route}>
<a
className={`${linkClasses} ${
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};
const SettingsTabs: React.FC<{
tabType?: 'default' | 'button';
settingsRoutes: SettingsRoute[];
}> = ({ tabType = 'default', settingsRoutes }) => {
const router = useRouter();
const { user: currentUser } = useUser();
return (
<>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a Tab
</label>
<select
onChange={(e) => {
router.push(e.target.value);
}}
onBlur={(e) => {
router.push(e.target.value);
}}
defaultValue={
settingsRoutes.find((route) => !!router.pathname.match(route.regex))
?.route
}
aria-label="Selected Tab"
>
{settingsRoutes
.filter(
(route) =>
!route.hidden &&
(route.requiredPermission
? hasPermission(
route.requiredPermission,
currentUser?.permissions ?? 0,
route.permissionType
)
: true)
)
.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
hidden={route.hidden ?? false}
isMobile
key={`mobile-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</select>
</div>
{tabType === 'button' ? (
<div className="hidden sm:block">
<nav className="flex flex-wrap -mx-2 -my-1" aria-label="Tabs">
{settingsRoutes.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
hidden={route.hidden ?? false}
key={`button-settings-link-${index}`}
>
{route.content ?? route.text}
</SettingsLink>
))}
</nav>
</div>
) : (
<div className="hidden overflow-x-scroll border-b border-gray-600 sm:block hide-scrollbar">
<nav className="flex">
{settingsRoutes
.filter(
(route) =>
!route.hidden &&
(route.requiredPermission
? hasPermission(
route.requiredPermission,
currentUser?.permissions ?? 0,
route.permissionType
)
: true)
)
.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
)}
</>
);
};
export default SettingsTabs;

View File

@@ -1,8 +1,9 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState, useEffect, useRef } from 'react';
import { XIcon } from '@heroicons/react/outline';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import Transition from '../../Transition';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
@@ -43,7 +44,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-70 bg-gray-800`}
onClick={() => onClose()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -70,9 +71,9 @@ const SlideOver: React.FC<SlideOverProps> = ({
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 py-6 space-y-1 bg-indigo-600">
<header className="px-4 space-y-1 bg-indigo-600 slideover">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg font-medium leading-7 text-white">
<h2 className="text-lg font-bold leading-7 text-white">
{title}
</h2>
<div className="flex items-center h-7">
@@ -81,20 +82,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<svg
className="w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<XIcon className="w-6 h-6" />
</button>
</div>
</div>

View File

@@ -13,7 +13,7 @@ const TH: React.FC<AllHTMLAttributes<HTMLTableHeaderCellElement>> = ({
...props
}) => {
const style = [
'px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider',
'px-4 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider truncate',
];
if (className) {
@@ -39,7 +39,7 @@ const TD: React.FC<TDProps> = ({
className,
...props
}) => {
const style = ['whitespace-nowrap text-sm leading-5 text-white'];
const style = ['text-sm leading-5 text-white'];
switch (alignText) {
case 'left':
@@ -54,7 +54,7 @@ const TD: React.FC<TDProps> = ({
}
if (!noPadding) {
style.push('px-6 py-4');
style.push('px-4 py-4');
}
if (className) {
@@ -73,7 +73,7 @@ const Table: React.FC = ({ children }) => {
<div className="flex flex-col">
<div className="my-2 -mx-4 overflow-x-auto md:mx-0 lg:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<div className="overflow-hidden rounded-lg shadow md:mx-0 lg:mx-0">
<table className="min-w-full">{children}</table>
</div>
</div>

View File

@@ -0,0 +1,48 @@
import Link from 'next/link';
import React, { useState } from 'react';
interface CompanyCardProps {
name: string;
image: string;
url: string;
}
const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
const [isHovered, setHovered] = useState(false);
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 w-56 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'
} rounded-xl`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<img
src={image}
alt={name}
className="relative z-40 max-w-full max-h-full"
/>
<div
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</a>
</Link>
);
};
export default CompanyCard;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
const messages = defineMessages({
genreMovies: '{genre} Movies',
});
const DiscoverMovieGenre: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<MovieResult, { genre: { id: number; name: string } }>(
`/api/v1/discover/movies/genre/${router.query.genreId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.genreMovies, {
genre: firstResultData?.genre.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieGenre;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
const messages = defineMessages({
languageMovies: '{language} Movies',
});
const DiscoverMovieLanguage: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<
MovieResult,
{
originalLanguage: {
iso_639_1: string;
english_name: string;
name: string;
};
}
>(`/api/v1/discover/movies/language/${router.query.language}`);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.languageMovies, {
language: intl.formatDisplayName(router.query.language as string, {
type: 'language',
fallback: 'none',
}),
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieLanguage;

View File

@@ -1,80 +1,40 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
discovermovies: 'Popular Movies',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const DiscoverMovies: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult>('/api/v1/discover/movies');
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
const title = intl.formatMessage(messages.discovermovies);
return (
<>
<PageTitle title={intl.formatMessage(messages.discovermovies)} />
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.discovermovies} />
</Header>
<Header>{title}</Header>
</div>
<ListView
items={titles}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import type { TvResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { TvNetwork } from '../../../../server/models/common';
const messages = defineMessages({
networkSeries: '{network} Series',
});
const DiscoverTvNetwork: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<TvResult, { network: TvNetwork }>(
`/api/v1/discover/tv/network/${router.query.networkId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.networkSeries, {
network: firstResultData?.network.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
{firstResultData?.network.logoPath ? (
<div className="flex justify-center mb-6">
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name}
className="max-h-24 sm:max-h-32"
/>
</div>
) : (
title
)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvNetwork;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { ProductionCompany } from '../../../../server/models/common';
const messages = defineMessages({
studioMovies: '{studio} Movies',
});
const DiscoverMovieStudio: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<MovieResult, { studio: ProductionCompany }>(
`/api/v1/discover/movies/studio/${router.query.studioId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.studioMovies, {
studio: firstResultData?.studio.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
{firstResultData?.studio.logoPath ? (
<div className="flex justify-center mb-6">
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name}
className="max-h-24 sm:max-h-32"
/>
</div>
) : (
title
)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieStudio;

View File

@@ -1,79 +1,40 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
discovertv: 'Popular Series',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: TvResult[];
}
const DiscoverTv: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult>('/api/v1/discover/tv');
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as TvResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
const title = intl.formatMessage(messages.discovertv);
return (
<>
<PageTitle title={intl.formatMessage(messages.discovertv)} />
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.discovertv} />
</Header>
<Header>{title}</Header>
</div>
<ListView
items={titles}

View File

@@ -0,0 +1,62 @@
import React from 'react';
import type { TvResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
const messages = defineMessages({
genreSeries: '{genre} Series',
});
const DiscoverTvGenre: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<TvResult, { genre: { id: number; name: string } }>(
`/api/v1/discover/tv/genre/${router.query.genreId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.genreSeries, {
genre: firstResultData?.genre.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvGenre;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import type { TvResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
const messages = defineMessages({
languageSeries: '{language} Series',
});
const DiscoverTvLanguage: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<
TvResult,
{
originalLanguage: {
iso_639_1: string;
english_name: string;
name: string;
};
}
>(`/api/v1/discover/tv/language/${router.query.language}`);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.languageSeries, {
language: intl.formatDisplayName(router.query.language as string, {
type: 'language',
fallback: 'none',
}),
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvLanguage;

View File

@@ -1,81 +1,38 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
upcomingtv: 'Upcoming Series',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: TvResult[];
}
const DiscoverTvUpcoming: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/tv/upcoming?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult>('/api/v1/discover/tv/upcoming');
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as TvResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.upcomingtv} />
</Header>
<Header>{intl.formatMessage(messages.upcomingtv)}</Header>
</div>
<ListView
items={titles}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import GenreCard from '../../GenreCard';
import { genreColorMap } from '../constants';
const messages = defineMessages({
moviegenres: 'Movie Genres',
});
const MovieGenreList: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.moviegenres)} />
<div className="mt-1 mb-5">
<Header>{intl.formatMessage(messages.moviegenres)}</Header>
</div>
<ul className="cards-horizontal">
{data.map((genre, index) => (
<li key={`genre-${genre.id}-${index}`}>
<GenreCard
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
canExpand
/>
</li>
))}
</ul>
</>
);
};
export default MovieGenreList;

View File

@@ -0,0 +1,56 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import GenreCard from '../../GenreCard';
import Slider from '../../Slider';
import { genreColorMap } from '../constants';
const messages = defineMessages({
moviegenres: 'Movie Genres',
});
const MovieGenreSlider: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
return (
<>
<div className="slider-header">
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="movie-genres"
isLoading={!data && !error}
isEmpty={false}
items={(data ?? []).map((genre, index) => (
<GenreCard
key={`genre-${genre.id}-${index}`}
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}
emptyMessage=""
/>
</>
);
};
export default React.memo(MovieGenreSlider);

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import CompanyCard from '../../CompanyCard';
import Slider from '../../Slider';
const messages = defineMessages({
networks: 'Networks',
});
interface Network {
name: string;
image: string;
url: string;
}
const networks: Network[] = [
{
name: 'Netflix',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
url: '/discover/tv/network/213',
},
{
name: 'Disney+',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
url: '/discover/tv/network/2739',
},
{
name: 'Prime Video',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
url: '/discover/tv/network/1024',
},
{
name: 'Apple TV+',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
url: '/discover/tv/network/2552',
},
{
name: 'HBO',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
url: '/discover/tv/network/49',
},
{
name: 'ABC',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
url: '/discover/tv/network/2',
},
{
name: 'FOX',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
url: '/discover/tv/network/19',
},
{
name: 'Cinemax',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
url: '/discover/tv/network/359',
},
{
name: 'AMC',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png',
url: '/discover/tv/network/174',
},
{
name: 'Showtime',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
url: '/discover/tv/network/67',
},
{
name: 'Starz',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
url: '/discover/tv/network/318',
},
{
name: 'The CW',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
url: '/discover/tv/network/71',
},
{
name: 'NBC',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
url: '/discover/tv/network/6',
},
{
name: 'CBS',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
url: '/discover/tv/network/16',
},
{
name: 'BBC One',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
url: '/discover/tv/network/4',
},
{
name: 'Cartoon Network',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
url: '/discover/tv/network/56',
},
{
name: 'Adult Swim',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
url: '/discover/tv/network/80',
},
{
name: 'Nickelodeon',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
url: '/discover/tv/network/13',
},
];
const NetworkSlider: React.FC = () => {
const intl = useIntl();
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.networks)}</span>
</div>
</div>
<Slider
sliderKey="networks"
isLoading={false}
isEmpty={false}
items={networks.map((network, index) => (
<CompanyCard
key={`network-${index}`}
name={network.name}
image={network.image}
url={network.url}
/>
))}
emptyMessage=""
/>
</>
);
};
export default NetworkSlider;

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import CompanyCard from '../../CompanyCard';
import Slider from '../../Slider';
const messages = defineMessages({
studios: 'Studios',
});
interface Studio {
name: string;
image: string;
url: string;
}
const studios: Studio[] = [
{
name: 'Disney',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png',
url: '/discover/movies/studio/2',
},
{
name: '20th Century Fox',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
url: '/discover/movies/studio/25',
},
{
name: 'Sony Pictures',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png',
url: '/discover/movies/studio/34',
},
{
name: 'Warner Bros. Pictures',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png',
url: '/discover/movies/studio/174',
},
{
name: 'Universal',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
url: '/discover/movies/studio/33',
},
{
name: 'Paramount',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png',
url: '/discover/movies/studio/4',
},
{
name: 'Pixar',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
url: '/discover/movies/studio/3',
},
{
name: 'Dreamworks',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
url: '/discover/movies/studio/521',
},
{
name: 'Marvel Studios',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
url: '/discover/movies/studio/420',
},
{
name: 'DC',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png',
url: '/discover/movies/studio/9993',
},
];
const StudioSlider: React.FC = () => {
const intl = useIntl();
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.studios)}</span>
</div>
</div>
<Slider
sliderKey="studios"
isLoading={false}
isEmpty={false}
items={studios.map((studio, index) => (
<CompanyCard
key={`studio-${index}`}
name={studio.name}
image={studio.image}
url={studio.url}
/>
))}
emptyMessage=""
/>
</>
);
};
export default StudioSlider;

View File

@@ -1,86 +1,43 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import React from 'react';
import type {
MovieResult,
TvResult,
PersonResult,
} from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
trending: 'Trending',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: (MovieResult | TvResult | PersonResult)[];
}
const Trending: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/trending?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult | TvResult | PersonResult>(
'/api/v1/discover/trending'
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as (MovieResult | TvResult | PersonResult)[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle title={intl.formatMessage(messages.trending)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.trending} />
</Header>
<Header>{intl.formatMessage(messages.trending)}</Header>
</div>
<ListView
items={titles}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import GenreCard from '../../GenreCard';
import { genreColorMap } from '../constants';
const messages = defineMessages({
seriesgenres: 'Series Genres',
});
const TvGenreList: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.seriesgenres)} />
<div className="mt-1 mb-5">
<Header>{intl.formatMessage(messages.seriesgenres)}</Header>
</div>
<ul className="cards-horizontal">
{data.map((genre, index) => (
<li key={`genre-${genre.id}-${index}`}>
<GenreCard
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
canExpand
/>
</li>
))}
</ul>
</>
);
};
export default TvGenreList;

View File

@@ -0,0 +1,56 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import GenreCard from '../../GenreCard';
import Slider from '../../Slider';
import { genreColorMap } from '../constants';
const messages = defineMessages({
tvgenres: 'Series Genres',
});
const TvGenreSlider: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
return (
<>
<div className="slider-header">
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="tv-genres"
isLoading={!data && !error}
isEmpty={false}
items={(data ?? []).map((genre, index) => (
<GenreCard
key={`genre-tv-${genre.id}-${index}`}
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}
emptyMessage=""
/>
</>
);
};
export default React.memo(TvGenreSlider);

View File

@@ -1,81 +1,38 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const UpcomingMovies: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/movies/upcoming?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult>('/api/v1/discover/movies/upcoming');
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.upcomingmovies} />
</Header>
<Header>{intl.formatMessage(messages.upcomingmovies)}</Header>
</div>
<ListView
items={titles}

View File

@@ -0,0 +1,63 @@
type AvailableColors =
| 'black'
| 'red'
| 'darkred'
| 'blue'
| 'lightblue'
| 'darkblue'
| 'orange'
| 'darkorange'
| 'green'
| 'lightgreen'
| 'purple'
| 'darkpurple'
| 'yellow'
| 'pink';
export const colorTones: Record<AvailableColors, [string, string]> = {
red: ['991B1B', 'FCA5A5'],
darkred: ['1F2937', 'F87171'],
blue: ['032541', '01b4e4'],
lightblue: ['1F2937', '60A5FA'],
darkblue: ['1F2937', '2864d2'],
orange: ['92400E', 'FCD34D'],
lightgreen: ['065F46', '6EE7B7'],
green: ['087d29', '21cb51'],
purple: ['5B21B6', 'C4B5FD'],
yellow: ['777e0d', 'e4ed55'],
darkorange: ['552c01', 'd47c1d'],
black: ['1F2937', 'D1D5DB'],
pink: ['9D174D', 'F9A8D4'],
darkpurple: ['480c8b', 'a96bef'],
};
export const genreColorMap: Record<number, [string, string]> = {
0: colorTones.black,
28: colorTones.red, // Action
12: colorTones.darkpurple, // Adventure
16: colorTones.blue, // Animation
35: colorTones.orange, // Comedy
80: colorTones.darkblue, // Crime
99: colorTones.lightgreen, // Documentary
18: colorTones.pink, // Drama
10751: colorTones.yellow, // Family
14: colorTones.lightblue, // Fantasy
36: colorTones.orange, // History
27: colorTones.black, // Horror
10402: colorTones.blue, // Music
9648: colorTones.purple, // Mystery
10749: colorTones.pink, // Romance
878: colorTones.lightblue, // Science Fiction
10770: colorTones.red, // TV Movie
53: colorTones.black, // Thriller
10752: colorTones.darkred, // War
37: colorTones.orange, // Western
10759: colorTones.darkpurple, // Action & Adventure
10762: colorTones.blue, // Kids
10763: colorTones.black, // News
10764: colorTones.darkorange, // Reality
10765: colorTones.lightblue, // Sci-Fi & Fantasy
10766: colorTones.pink, // Soap
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};

View File

@@ -1,14 +1,19 @@
import React from 'react';
import useSWR from 'swr';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import Slider from '../Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import RequestCard from '../RequestCard';
import MediaSlider from '../MediaSlider';
import PageTitle from '../Common/PageTitle';
import MediaSlider from '../MediaSlider';
import RequestCard from '../RequestCard';
import Slider from '../Slider';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import MovieGenreSlider from './MovieGenreSlider';
import NetworkSlider from './NetworkSlider';
import StudioSlider from './StudioSlider';
import TvGenreSlider from './TvGenreSlider';
const messages = defineMessages({
discover: 'Discover',
@@ -17,7 +22,7 @@ const messages = defineMessages({
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
nopending: 'No Pending Requests',
noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
});
@@ -26,26 +31,24 @@ const Discover: React.FC = () => {
const intl = useIntl();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
const {
data: requests,
error: requestError,
} = useSWR<RequestResultsResponse>(
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0'
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
);
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recentlyAdded} />
</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
@@ -60,30 +63,13 @@ const Discover: React.FC = () => {
/>
))}
/>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/requests">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recentrequests} />
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
@@ -96,7 +82,7 @@ const Discover: React.FC = () => {
/>
))}
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)}
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
<MediaSlider
sliderKey="trending"
@@ -110,24 +96,28 @@ const Discover: React.FC = () => {
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<StudioSlider />
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
<NetworkSlider />
</>
);
};

View File

@@ -1,8 +1,12 @@
import React from 'react';
import { FormattedRelativeTime } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { DownloadingItem } from '../../../server/lib/downloadtracker';
import Badge from '../Common/Badge';
const messages = defineMessages({
estimatedtime: 'Estimated {time}',
});
interface DownloadBlockProps {
downloadItem: DownloadingItem;
is4k?: boolean;
@@ -12,6 +16,8 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
downloadItem,
is4k = false,
}) => {
const intl = useIntl();
return (
<div className="p-4">
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
@@ -48,26 +54,30 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
<div className="flex items-center justify-between text-xs">
<span>
{is4k && (
<Badge badgeType="warning" className="mr-1">
<Badge badgeType="warning" className="mr-2">
4K
</Badge>
)}
<Badge className="capitalize">{downloadItem.status}</Badge>
</span>
<span>
ETA{' '}
{downloadItem.estimatedCompletionTime ? (
<FormattedRelativeTime
value={Math.floor(
(new Date(downloadItem.estimatedCompletionTime).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
) : (
'N/A'
)}
{downloadItem.estimatedCompletionTime
? intl.formatMessage(messages.estimatedtime, {
time: (
<FormattedRelativeTime
value={Math.floor(
(new Date(
downloadItem.estimatedCompletionTime
).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
})
: ''}
</span>
</div>
</div>

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { MediaType } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import ImdbLogo from '../../assets/services/imdb.svg';
import JellyfinLogo from '../../assets/services/jellyfin.svg';
import PlexLogo from '../../assets/services/plex.svg';
import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
import JellyfinLogo from '../../assets/services/jellyfin.svg';
import { MediaType } from '../../../server/constants/media';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import { MediaServerType } from '../../../server/constants/server';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@@ -27,6 +28,8 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
mediaUrl,
}) => {
const settings = useSettings();
const { locale } = useLocale();
return (
<div className="flex items-center justify-end">
{mediaUrl && (
@@ -49,8 +52,8 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
)}
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}?language=${locale}`}
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -60,7 +63,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tvdbId && mediaType === MediaType.TV && (
<a
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="transition duration-300 opacity-50 w-9 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -70,7 +73,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -80,7 +83,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{rtUrl && (
<a
href={`${rtUrl}`}
className="mx-2 transition duration-300 opacity-50 w-14 hover:opacity-100"
className="transition duration-300 opacity-50 w-14 hover:opacity-100"
target="_blank"
rel="noreferrer"
>

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import React, { useState } from 'react';
import { withProperties } from '../../utils/typeHelpers';
import CachedImage from '../Common/CachedImage';
interface GenreCardProps {
name: string;
image: string;
url: string;
canExpand?: boolean;
}
const GenreCard: React.FC<GenreCardProps> = ({
image,
url,
name,
canExpand = false,
}) => {
const [isHovered, setHovered] = useState(false);
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72'
} p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
} rounded-xl bg-cover bg-center overflow-hidden`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
<div
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`}
/>
<div className="relative z-20 w-full text-2xl font-bold text-center text-white truncate whitespace-normal sm:text-3xl">
{name}
</div>
</a>
</Link>
);
};
const GenreCardPlaceholder: React.FC = () => {
return (
<div
className={`relative h-32 w-56 sm:h-40 sm:w-72 animate-pulse rounded-xl bg-gray-700`}
></div>
);
};
export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder });

View File

@@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { sortBy } from 'lodash';
import dynamic from 'next/dynamic';
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import useSWR from 'swr';
import { Language } from '../../../server/lib/settings';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
originalLanguageDefault: 'All Languages',
languageServerDefault: 'Default ({language})',
});
const Select = dynamic(() => import('react-select'), { ssr: false });
type OptionType = {
value: string;
label: string;
isFixed?: boolean;
};
const selectStyles = {
multiValueLabel: (base: any, state: { data: { isFixed?: boolean } }) => {
return state.data.isFixed ? { ...base, paddingRight: 6 } : base;
},
multiValueRemove: (base: any, state: { data: { isFixed?: boolean } }) => {
return state.data.isFixed ? { ...base, display: 'none' } : base;
},
};
interface LanguageSelectorProps {
value?: string;
setFieldValue: (property: string, value: string) => void;
serverValue?: string;
isUserSettings?: boolean;
}
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
value,
setFieldValue,
serverValue,
isUserSettings = false,
}) => {
const intl = useIntl();
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const sortedLanguages = useMemo(() => {
languages?.forEach((language) => {
language.name =
intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name;
});
return sortBy(languages, 'name');
}, [intl, languages]);
const languageName = (languageCode: string) =>
sortedLanguages?.find((language) => language.iso_639_1 === languageCode)
?.name ?? languageCode;
const options: OptionType[] =
sortedLanguages?.map((language) => ({
label: language.name,
value: language.iso_639_1,
})) ?? [];
if (isUserSettings) {
options.unshift({
value: 'server',
label: intl.formatMessage(messages.languageServerDefault, {
language: serverValue
? serverValue
.split('|')
.map((value) => languageName(value))
.reduce((prev, curr) =>
intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})
)
: intl.formatMessage(messages.originalLanguageDefault),
}),
isFixed: true,
});
}
options.unshift({
value: 'all',
label: intl.formatMessage(messages.originalLanguageDefault),
isFixed: true,
});
return (
<Select
options={options}
isMulti
className="react-select-container"
classNamePrefix="react-select"
value={
(isUserSettings && value === 'all') || (!isUserSettings && !value)
? {
value: 'all',
label: intl.formatMessage(messages.originalLanguageDefault),
isFixed: true,
}
: (value === '' || !value || value === 'server') && isUserSettings
? {
value: 'server',
label: intl.formatMessage(messages.languageServerDefault, {
language: serverValue
? serverValue
.split('|')
.map((value) => languageName(value))
.reduce((prev, curr) =>
intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})
)
: intl.formatMessage(messages.originalLanguageDefault),
}),
isFixed: true,
}
: value?.split('|').map((code) => {
const matchedLanguage = sortedLanguages?.find(
(lang) => lang.iso_639_1 === code
);
if (!matchedLanguage) {
return undefined;
}
return {
label: matchedLanguage.name,
value: matchedLanguage.iso_639_1,
};
}) ?? undefined
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null,
options
) => {
if (!Array.isArray(value)) {
return;
}
if (
(options &&
options.action === 'select-option' &&
options.option?.value === 'server') ||
value?.every(
(v: { value: string; label: string }) => v.value === 'server'
)
) {
return setFieldValue('originalLanguage', '');
}
if (
(options &&
options.action === 'select-option' &&
options.option?.value === 'all') ||
value?.every(
(v: { value: string; label: string }) => v.value === 'all'
)
) {
return setFieldValue('originalLanguage', isUserSettings ? 'all' : '');
}
setFieldValue(
'originalLanguage',
value
?.map((lang) => lang.value)
.filter((v) => v !== 'all')
.join('|')
);
}}
styles={selectStyles}
/>
);
};
export default LanguageSelector;

View File

@@ -1,87 +1,22 @@
import React, { useState, useRef, useContext } from 'react';
import Transition from '../../Transition';
import useClickOutside from '../../../hooks/useClickOutside';
import { TranslateIcon } from '@heroicons/react/solid';
import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
LanguageContext,
AvailableLocales,
availableLanguages,
AvailableLocale,
} from '../../../context/LanguageContext';
import { FormattedMessage, defineMessages } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import useLocale from '../../../hooks/useLocale';
import Transition from '../../Transition';
const messages = defineMessages({
changelanguage: 'Change Language',
displaylanguage: 'Display Language',
});
type AvailableLanguageObject = Record<
string,
{ code: AvailableLocales; display: string }
>;
const availableLanguages: AvailableLanguageObject = {
de: {
code: 'de',
display: 'Deutsch',
},
en: {
code: 'en',
display: 'English',
},
es: {
code: 'es',
display: 'Español',
},
fr: {
code: 'fr',
display: 'Français',
},
it: {
code: 'it',
display: 'Italiano',
},
hu: {
code: 'hu',
display: 'Magyar',
},
nl: {
code: 'nl',
display: 'Nederlands',
},
'nb-NO': {
code: 'nb-NO',
display: 'Norsk Bokmål',
},
'pt-BR': {
code: 'pt-BR',
display: 'Português (Brasil)',
},
'pt-PT': {
code: 'pt-PT',
display: 'Português (Portugal)',
},
sv: {
code: 'sv',
display: 'Svenska',
},
ru: {
code: 'ru',
display: 'pусский',
},
sr: {
code: 'sr',
display: 'српски језик‬',
},
ja: {
code: 'ja',
display: '日本語',
},
'zh-TW': {
code: 'zh-TW',
display: '中文(臺灣)',
},
};
const LanguagePicker: React.FC = () => {
const intl = useIntl();
const dropdownRef = useRef<HTMLDivElement>(null);
const { locale, setLocale } = useContext(LanguageContext);
const { locale, setLocale } = useLocale();
const [isDropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(dropdownRef, () => setDropdownOpen(false));
@@ -89,22 +24,13 @@ const LanguagePicker: React.FC = () => {
<div className="relative">
<div>
<button
className="p-1 text-gray-400 rounded-full hover:bg-gray-600 hover:text-white focus:outline-none focus:ring focus:text-white"
className={`p-1 rounded-full sm:p-2 hover:bg-gray-600 hover:text-white focus:outline-none focus:bg-gray-600 focus:ring-1 focus:ring-gray-500 focus:text-white ${
isDropdownOpen ? 'bg-gray-600 text-white' : 'text-gray-400'
}`}
aria-label="Language Picker"
onClick={() => setDropdownOpen(true)}
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M7 2a1 1 0 011 1v1h3a1 1 0 110 2H9.578a18.87 18.87 0 01-1.724 4.78c.29.354.596.696.914 1.026a1 1 0 11-1.44 1.389c-.188-.196-.373-.396-.554-.6a19.098 19.098 0 01-3.107 3.567 1 1 0 01-1.334-1.49 17.087 17.087 0 003.13-3.733 18.992 18.992 0 01-1.487-2.494 1 1 0 111.79-.89c.234.47.489.928.764 1.372.417-.934.752-1.913.997-2.927H3a1 1 0 110-2h3V3a1 1 0 011-1zm6 6a1 1 0 01.894.553l2.991 5.982a.869.869 0 01.02.037l.99 1.98a1 1 0 11-1.79.895L15.383 16h-4.764l-.724 1.447a1 1 0 11-1.788-.894l.99-1.98.019-.038 2.99-5.982A1 1 0 0113 8zm-1.382 6h2.764L13 11.236 11.618 14z"
clipRule="evenodd"
/>
</svg>
<TranslateIcon className="w-6 h-6" />
</button>
</div>
<Transition
@@ -124,18 +50,18 @@ const LanguagePicker: React.FC = () => {
<div>
<label
htmlFor="language"
className="block pb-2 text-sm font-medium leading-5 text-gray-300"
className="block pb-2 text-sm font-bold leading-5 text-gray-300"
>
<FormattedMessage {...messages.changelanguage} />
{intl.formatMessage(messages.displaylanguage)}
</label>
<select
id="language"
className="rounded-md"
onChange={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
setLocale && setLocale(e.target.value as AvailableLocale)
}
onBlur={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
setLocale && setLocale(e.target.value as AvailableLocale)
}
defaultValue={locale}
>

View File

@@ -1,3 +1,4 @@
import { BellIcon } from '@heroicons/react/outline';
import React from 'react';
const Notifications: React.FC = () => {
@@ -6,19 +7,7 @@ const Notifications: React.FC = () => {
className="p-1 text-gray-400 rounded-full hover:bg-gray-500 hover:text-white focus:outline-none focus:ring focus:text-white"
aria-label="Notifications"
>
<svg
className="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<BellIcon className="w-6 h-6" />
</button>
);
};

View File

@@ -1,7 +1,8 @@
import { XCircleIcon } from '@heroicons/react/outline';
import { SearchIcon } from '@heroicons/react/solid';
import React from 'react';
import useSearchInput from '../../../hooks/useSearchInput';
import { defineMessages, useIntl } from 'react-intl';
import ClearButton from '../../../assets/xcircle.svg';
import useSearchInput from '../../../hooks/useSearchInput';
const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV',
@@ -12,26 +13,21 @@ const SearchInput: React.FC = () => {
const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
return (
<div className="flex flex-1">
<div className="flex w-full md:ml-0">
<div className="flex w-full">
<label htmlFor="search_field" className="sr-only">
Search
</label>
<div className="relative flex items-center w-full text-white focus-within:text-gray-200">
<div className="absolute inset-y-0 flex items-center pointer-events-none left-4">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
/>
</svg>
<SearchIcon className="w-5 h-5" />
</div>
<input
id="search_field"
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full bg-opacity-80 focus:bg-opacity-100 focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search"
inputMode="search"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setIsOpen(true)}
@@ -46,7 +42,7 @@ const SearchInput: React.FC = () => {
className="absolute inset-y-0 p-1 m-auto text-gray-400 transition border-none outline-none right-2 h-7 w-7 focus:outline-none focus:border-none hover:text-white"
onClick={() => clear()}
>
<ClearButton />
<XCircleIcon className="w-5 h-5" />
</button>
)}
</div>

View File

@@ -1,10 +1,18 @@
import React, { ReactNode, useRef } from 'react';
import Transition from '../../Transition';
import {
ClockIcon,
CogIcon,
SparklesIcon,
UsersIcon,
XIcon,
} from '@heroicons/react/outline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
import { useUser, Permission } from '../../../hooks/useUser';
import React, { ReactNode, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import { Permission, useUser } from '../../../hooks/useUser';
import Transition from '../../Transition';
import VersionStatus from '../VersionStatus';
const messages = defineMessages({
dashboard: 'Discover',
@@ -31,86 +39,26 @@ const SidebarLinks: SidebarLinkProps[] = [
{
href: '/',
messagesKey: 'dashboard',
svgIcon: (
<svg
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
),
svgIcon: <SparklesIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
},
{
href: '/requests',
messagesKey: 'requests',
svgIcon: (
<svg
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
svgIcon: <ClockIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/requests/,
},
{
href: '/users',
messagesKey: 'users',
svgIcon: (
<svg
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
svgIcon: <UsersIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/users/,
requiredPermission: Permission.MANAGE_USERS,
},
{
href: '/settings',
messagesKey: 'settings',
svgIcon: (
<svg
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
svgIcon: <CogIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/settings/,
requiredPermission: Permission.MANAGE_SETTINGS,
},
@@ -119,11 +67,13 @@ const SidebarLinks: SidebarLinkProps[] = [
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const intl = useIntl();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
return (
<>
<div className="md:hidden">
<div className="lg:hidden">
<Transition show={open}>
<div className="fixed inset-0 z-40 flex">
<Transition
@@ -135,7 +85,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
leaveTo="opacity-0"
>
<div className="fixed inset-0">
<div className="absolute inset-0 bg-gray-600 opacity-75"></div>
<div className="absolute inset-0 bg-gray-900 opacity-90"></div>
</div>
</Transition>
<Transition
@@ -147,40 +97,28 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
leaveTo="-translate-x-full"
>
<>
<div className="relative flex flex-col flex-1 w-full max-w-xs bg-gray-800">
<div className="absolute top-0 right-0 p-1 -mr-14">
<div className="relative flex flex-col flex-1 w-full max-w-xs bg-gray-800 sidebar">
<div className="absolute top-0 right-0 p-1 sidebar-close-button -mr-14">
<button
className="flex items-center justify-center w-12 h-12 rounded-full focus:outline-none focus:bg-gray-600"
aria-label="Close sidebar"
onClick={() => setClosed()}
>
<svg
className="w-6 h-6 text-white"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<XIcon className="w-6 h-6 text-white" />
</button>
</div>
<div
ref={navRef}
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
className="flex flex-col flex-1 h-0 pt-8 pb-8 overflow-y-auto sm:pb-4"
>
<div className="flex items-center flex-shrink-0 px-4">
<span className="text-xl text-gray-50">
<div className="flex items-center flex-shrink-0 px-2">
<span className="px-4 text-xl text-gray-50">
<a href="/">
<img src="/logo.png" alt="Logo" />
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="px-2 mt-5 space-y-1">
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
@@ -201,25 +139,30 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
}}
role="button"
tabIndex={0}
className={`flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
className={`flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none transition ease-in-out duration-150
${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'bg-gray-900'
: ''
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
>
{sidebarLink.svgIcon}
<FormattedMessage
{...messages[sidebarLink.messagesKey]}
/>
{intl.formatMessage(
messages[sidebarLink.messagesKey]
)}
</a>
</Link>
);
})}
</nav>
{hasPermission(Permission.ADMIN) && (
<div className="px-2">
<VersionStatus onClick={() => setClosed()} />
</div>
)}
</div>
</div>
<div className="flex-shrink-0 w-14">
@@ -231,18 +174,18 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</Transition>
</div>
<div className="fixed top-0 bottom-0 left-0 hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col flex-1 h-0 bg-gray-800">
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4">
<span className="text-2xl text-gray-50">
<div className="fixed top-0 bottom-0 left-0 z-30 hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64 sidebar">
<div className="flex flex-col flex-1 h-0">
<div className="flex flex-col flex-1 pt-8 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0">
<span className="px-4 text-2xl text-gray-50">
<a href="/">
<img src="/logo.png" alt="Logo" />
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="flex-1 px-2 mt-5 space-y-1 bg-gray-800">
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
@@ -255,25 +198,28 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
as={sidebarLink.as}
>
<a
className={`flex group items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white hover:text-gray-100 hover:bg-gray-700 focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
className={`flex group items-center px-2 py-2 text-lg leading-6 font-medium rounded-md text-white focus:outline-none transition ease-in-out duration-150
${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'bg-gray-900'
: ''
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
>
{sidebarLink.svgIcon}
<FormattedMessage
{...messages[sidebarLink.messagesKey]}
/>
{intl.formatMessage(messages[sidebarLink.messagesKey])}
</a>
</Link>
);
})}
</nav>
{hasPermission(Permission.ADMIN) && (
<div className="px-2">
<VersionStatus />
</div>
)}
</div>
</div>
</div>

View File

@@ -1,10 +1,12 @@
import React, { useState, useRef } from 'react';
import Transition from '../../Transition';
import { useUser } from '../../../hooks/useUser';
import { LogoutIcon } from '@heroicons/react/outline';
import { CogIcon, UserIcon } from '@heroicons/react/solid';
import axios from 'axios';
import useClickOutside from '../../../hooks/useClickOutside';
import { defineMessages, useIntl } from 'react-intl';
import Link from 'next/link';
import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import { useUser } from '../../../hooks/useUser';
import Transition from '../../Transition';
const messages = defineMessages({
myprofile: 'Profile',
@@ -31,13 +33,17 @@ const UserDropdown: React.FC = () => {
<div className="relative ml-3">
<div>
<button
className="flex items-center max-w-xs text-sm rounded-full focus:outline-none focus:ring"
className="flex items-center max-w-xs text-sm rounded-full ring-1 ring-gray-700 focus:outline-none focus:ring-gray-500 hover:ring-gray-500"
id="user-menu"
aria-label="User menu"
aria-haspopup="true"
onClick={() => setDropdownOpen(true)}
>
<img className="w-8 h-8 rounded-full" src={user?.avatar} alt="" />
<img
className="w-8 h-8 rounded-full sm:w-10 sm:h-10"
src={user?.avatar}
alt=""
/>
</button>
</div>
<Transition
@@ -61,7 +67,7 @@ const UserDropdown: React.FC = () => {
>
<Link href={`/profile`}>
<a
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
role="menuitem"
tabIndex={0}
onKeyDown={(e) => {
@@ -71,12 +77,13 @@ const UserDropdown: React.FC = () => {
}}
onClick={() => setDropdownOpen(false)}
>
{intl.formatMessage(messages.myprofile)}
<UserIcon className="inline w-5 h-5 mr-2" />
<span>{intl.formatMessage(messages.myprofile)}</span>
</a>
</Link>
<Link href={`/profile/settings`}>
<a
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
role="menuitem"
tabIndex={0}
onKeyDown={(e) => {
@@ -86,16 +93,18 @@ const UserDropdown: React.FC = () => {
}}
onClick={() => setDropdownOpen(false)}
>
{intl.formatMessage(messages.settings)}
<CogIcon className="inline w-5 h-5 mr-2" />
<span>{intl.formatMessage(messages.settings)}</span>
</a>
</Link>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
role="menuitem"
onClick={() => logout()}
>
{intl.formatMessage(messages.signout)}
<LogoutIcon className="inline w-5 h-5 mr-2" />
<span>{intl.formatMessage(messages.signout)}</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,90 @@
import {
ArrowCircleUpIcon,
BeakerIcon,
CodeIcon,
ServerIcon,
} from '@heroicons/react/outline';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
const messages = defineMessages({
streamdevelop: 'Overseerr Develop',
streamstable: 'Overseerr Stable',
outofdate: 'Out of Date',
commitsbehind:
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
});
interface VersionStatusProps {
onClick?: () => void;
}
const VersionStatus: React.FC<VersionStatusProps> = ({ onClick }) => {
const intl = useIntl();
const { data } = useSWR<StatusResponse>('/api/v1/status', {
refreshInterval: 60 * 1000,
});
if (!data) {
return null;
}
const versionStream =
data.commitTag === 'local'
? 'Keep it up! 👍'
: data.version.startsWith('develop-')
? intl.formatMessage(messages.streamdevelop)
: intl.formatMessage(messages.streamstable);
return (
<Link href="/settings/about">
<a
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className={`flex items-center p-2 mx-2 text-xs transition duration-300 rounded-lg ring-1 ring-gray-700 ${
data.updateAvailable
? 'bg-yellow-500 text-white hover:bg-yellow-400'
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
}`}
>
{data.commitTag === 'local' ? (
<CodeIcon className="w-6 h-6" />
) : data.version.startsWith('develop-') ? (
<BeakerIcon className="w-6 h-6" />
) : (
<ServerIcon className="w-6 h-6" />
)}
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local' ? (
'(⌐■_■)'
) : data.commitsBehind > 0 ? (
intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind,
})
) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate)
) : (
<code className="p-0 bg-transparent">
{data.version.replace('develop-', '')}
</code>
)}
</span>
</div>
{data.updateAvailable && <ArrowCircleUpIcon className="w-6 h-6" />}
</a>
</Link>
);
};
export default VersionStatus;

View File

@@ -1,26 +1,36 @@
import React, { useEffect, useState } from 'react';
import SearchInput from './SearchInput';
import UserDropdown from './UserDropdown';
import Sidebar from './Sidebar';
import LanguagePicker from './LanguagePicker';
import { MenuAlt2Icon } from '@heroicons/react/outline';
import { ArrowLeftIcon } from '@heroicons/react/solid';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Permission, useUser } from '../../hooks/useUser';
const messages = defineMessages({
alphawarning:
'This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!',
});
import React, { useEffect, useState } from 'react';
import { AvailableLocale } from '../../context/LanguageContext';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
import SearchInput from './SearchInput';
import Sidebar from './Sidebar';
import UserDropdown from './UserDropdown';
const Layout: React.FC = ({ children }) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const { hasPermission } = useUser();
const { user } = useUser();
const router = useRouter();
const { currentSettings } = useSettings();
const { setLocale } = useLocale();
useEffect(() => {
if (setLocale && user) {
setLocale(
(user?.settings?.locale
? user.settings.locale
: currentSettings.locale) as AvailableLocale
);
}
}, [setLocale, currentSettings.locale, user]);
useEffect(() => {
const updateScrolled = () => {
if (window.pageYOffset > 60) {
if (window.pageYOffset > 20) {
setIsScrolled(true);
} else {
setIsScrolled(false);
@@ -36,44 +46,42 @@ const Layout: React.FC = ({ children }) => {
return (
<div className="flex h-full min-w-0 min-h-full bg-gray-900">
<div className="absolute w-full h-64 from-gray-800 to-gray-900 bg-gradient-to-bl">
<div className="fixed inset-0 z-20 w-full h-1 border-gray-700 md:border-t pwa-only" />
<div className="absolute top-0 w-full h-64 from-gray-800 to-gray-900 bg-gradient-to-bl">
<div className="relative inset-0 w-full h-full from-gray-900 to-transparent bg-gradient-to-t" />
</div>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 md:ml-64">
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 lg:ml-64">
<div
className={`fixed left-0 right-0 z-10 flex flex-shrink-0 h-16 bg-opacity-80 transition duration-300 ${
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 bg-opacity-80 transition duration-300 ${
isScrolled ? 'bg-gray-700' : 'bg-transparent'
} md:left-64`}
} lg:left-64`}
style={{
backdropFilter: isScrolled ? 'blur(5px)' : undefined,
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
}}
>
<button
className="px-4 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
className={`px-4 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} focus:outline-none lg:hidden transition duration-300`}
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
>
<svg
className="w-6 h-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
<MenuAlt2Icon className="w-6 h-6" />
</button>
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
<div className="flex items-center justify-between flex-1 pr-4 md:pr-4 md:pl-4">
<button
className={`mr-2 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} transition duration-300 hover:text-white pwa-only focus:outline-none focus:text-white`}
onClick={() => router.back()}
>
<ArrowLeftIcon className="w-7" />
</button>
<SearchInput />
<div className="flex items-center ml-2 md:ml-4">
<LanguagePicker />
<div className="flex items-center">
<UserDropdown />
</div>
</div>
@@ -81,44 +89,7 @@ const Layout: React.FC = ({ children }) => {
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
<div className="mb-6">
<div className="px-4 mx-auto max-w-8xl">
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
<div className="p-4 mt-6 bg-indigo-700 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-white"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 ml-3 md:flex md:justify-between">
<p className="text-sm leading-5 text-white">
<FormattedMessage {...messages.alphawarning} />
</p>
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
<a
href="http://github.com/sct/overseerr"
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
target="_blank"
rel="noreferrer"
>
GitHub &rarr;
</a>
</p>
</div>
</div>
</div>
)}
{children}
</div>
<div className="px-4 mx-auto max-w-8xl">{children}</div>
</div>
</main>
</div>

View File

@@ -0,0 +1,73 @@
import { NProgress } from '@tanem/react-nprogress';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useRouter } from 'next/router';
interface BarProps {
progress: number;
isFinished: boolean;
}
const Bar = ({ progress, isFinished }: BarProps) => {
return (
<div
className={`fixed top-0 left-0 z-50 w-full transition-opacity ease-out duration-400 ${
isFinished ? 'opacity-0' : 'opacity-100'
}`}
>
<div
className="duration-300 bg-indigo-400 transition-width"
style={{
height: '3px',
width: `${progress * 100}%`,
}}
/>
</div>
);
};
const NProgressBar = ({ loading }: { loading: boolean }) => (
<NProgress isAnimating={loading}>
{({ isFinished, progress }) => (
<Bar progress={progress} isFinished={isFinished} />
)}
</NProgress>
);
const MemoizedNProgress = React.memo(NProgressBar);
const LoadingBar = (): React.ReactPortal | null => {
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleLoading = () => {
setLoading(true);
};
const handleFinishedLoading = () => {
setLoading(false);
};
router.events.on('routeChangeStart', handleLoading);
router.events.on('routeChangeComplete', handleFinishedLoading);
router.events.on('routeChangeError', handleFinishedLoading);
return () => {
router.events.off('routeChangeStart', handleLoading);
router.events.off('routeChangeComplete', handleFinishedLoading);
router.events.off('routeChangeError', handleFinishedLoading);
};
}, [router]);
return mounted
? ReactDOM.createPortal(
<MemoizedNProgress loading={loading} />,
document.body
)
: null;
};
export default LoadingBar;

View File

@@ -1,18 +1,21 @@
import { LoginIcon, SupportIcon } from '@heroicons/react/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import { Field, Form, Formik } from 'formik';
import * as Yup from 'yup';
import axios from 'axios';
import Link from 'next/link';
import useSettings from '../../hooks/useSettings';
import Button from '../Common/Button';
import SensitiveInput from '../Common/SensitiveInput';
const messages = defineMessages({
email: 'Email Address',
password: 'Password',
validationemailrequired: 'Not a valid email address',
validationpasswordrequired: 'Password required',
validationemailrequired: 'You must provide a valid email address',
validationpasswordrequired: 'You must provide a password',
loginerror: 'Something went wrong while trying to sign in.',
signingin: 'Signing in…',
signingin: 'Signing In…',
signin: 'Sign In',
forgotpassword: 'Forgot Password?',
});
@@ -23,6 +26,7 @@ interface LocalLoginProps {
const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
const intl = useIntl();
const settings = useSettings();
const [loginError, setLoginError] = useState<string | null>(null);
const LoginSchema = Yup.object().shape({
@@ -34,6 +38,10 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
),
});
const passwordResetEnabled =
settings.currentSettings.applicationUrl &&
settings.currentSettings.emailEnabled;
return (
<Formik
initialValues={{
@@ -58,17 +66,17 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
return (
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
placeholder="name@example.com"
inputMode="email"
/>
</div>
{errors.email && touched.email && (
@@ -79,12 +87,13 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
<div className="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
autoComplete="current-password"
/>
</div>
{errors.password && touched.password && (
@@ -98,25 +107,33 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
)}
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
{intl.formatMessage(messages.forgotpassword)}
</Button>
</Link>
</span>
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
<LoginIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</Button>
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<SupportIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
)}
</div>
</div>
</Form>

View File

@@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react';
import PlexLoginButton from '../PlexLoginButton';
import JellyfinLogin from './JellyfinLogin';
import { useUser } from '../../hooks/useUser';
import { XCircleIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import ImageFader from '../Common/ImageFader';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Transition from '../Transition';
import LanguagePicker from '../Layout/LanguagePicker';
import LocalLogin from './LocalLogin';
import Accordion from '../Common/Accordion';
import useSettings from '../../hooks/useSettings';
import PageTitle from '../Common/PageTitle';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
import Accordion from '../Common/Accordion';
import ImageFader from '../Common/ImageFader';
import PageTitle from '../Common/PageTitle';
import LanguagePicker from '../Layout/LanguagePicker';
import PlexLoginButton from '../PlexLoginButton';
import Transition from '../Transition';
import JellyfinLogin from './JellyfinLogin';
import LocalLogin from './LocalLogin';
const messages = defineMessages({
signin: 'Sign In',
@@ -66,6 +67,7 @@ const Login: React.FC = () => {
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} />
<ImageFader
forceOptimize
backgroundImages={[
'/images/rotate1.jpg',
'/images/rotate2.jpg',
@@ -78,10 +80,10 @@ const Login: React.FC = () => {
<div className="absolute z-50 top-4 right-4">
<LanguagePicker />
</div>
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo.png" className="w-auto mx-auto max-h-32" alt="Logo" />
<div className="relative z-40 flex flex-col items-center px-4 mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="max-w-full mb-10" alt="Logo" />
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<FormattedMessage {...messages.signinheader} />
{intl.formatMessage(messages.signinheader)}
</h2>
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
@@ -102,19 +104,7 @@ const Login: React.FC = () => {
<div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-red-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<XCircleIcon className="w-5 h-5 text-red-300" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-300">
@@ -128,7 +118,7 @@ const Login: React.FC = () => {
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 sm:rounded-t-lg ${
className={`font-bold w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
@@ -158,7 +148,7 @@ const Login: React.FC = () => {
{settings.currentSettings.localLogin && (
<div>
<button
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 hover:bg-gray-700 hover:cursor-pointer ${
className={`font-bold w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 hover:bg-gray-700 hover:cursor-pointer ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'

View File

@@ -1,3 +1,4 @@
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
import Link from 'next/link';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -32,8 +33,10 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
>
<div
className={`relative w-36 sm:w-36 md:w-44
rounded-lg text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ${
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
rounded-xl text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-600 ring-gray-500 scale-105'
: 'bg-gray-800 ring-gray-700 scale-100'
}`}
>
<div style={{ paddingBottom: '150%' }}>
@@ -77,18 +80,7 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
)}
</div>
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
<svg
className="w-14"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z"
clipRule="evenodd"
/>
</svg>
<ArrowCircleRightIcon className="w-14" />
<div className="mt-2 font-extrabold">
{intl.formatMessage(messages.seemore)}
</div>

View File

@@ -1,5 +1,6 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import React, { useContext, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useSWRInfinite } from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type {
@@ -7,7 +8,6 @@ import type {
PersonResult,
TvResult,
} from '../../../server/models/Search';
import { LanguageContext } from '../../context/LanguageContext';
import useSettings from '../../hooks/useSettings';
import PersonCard from '../PersonCard';
import Slider from '../Slider';
@@ -37,14 +37,13 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
hideWhenEmpty = false,
}) => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
(pageIndex: number, previousPageData: MixedResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `${url}?page=${pageIndex + 1}&language=${locale}`;
return `${url}?page=${pageIndex + 1}`;
},
{
initialSize: 2,
@@ -135,34 +134,19 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
return (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
{linkUrl ? (
<Link href={linkUrl}>
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{title}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
) : (
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
<div className="slider-header">
{linkUrl ? (
<Link href={linkUrl}>
<a className="slider-title">
<span>{title}</span>
</div>
)}
</div>
<ArrowCircleRightIcon />
</a>
</Link>
) : (
<div className="slider-title">
<span>{title}</span>
</div>
)}
</div>
<Slider
sliderKey={sliderKey}

View File

@@ -1,15 +1,14 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcast: 'Full Cast',
@@ -18,9 +17,8 @@ const messages = defineMessages({
const MovieCast: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
`/api/v1/movie/${router.query.movieId}`
);
if (!data && !error) {
@@ -45,7 +43,7 @@ const MovieCast: React.FC = () => {
{intl.formatMessage(messages.fullcast)}
</Header>
</div>
<ul className="cardList">
<ul className="cards-vertical">
{data?.credits.cast.map((person, index) => {
return (
<li key={`cast-${person.id}-${index}`}>

View File

@@ -1,15 +1,14 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcrew: 'Full Crew',
@@ -18,9 +17,8 @@ const messages = defineMessages({
const MovieCrew: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
`/api/v1/movie/${router.query.movieId}`
);
if (!data && !error) {
@@ -45,7 +43,7 @@ const MovieCrew: React.FC = () => {
{intl.formatMessage(messages.fullcrew)}
</Header>
</div>
<ul className="cardList">
<ul className="cards-vertical">
{data?.credits.crew.map((person, index) => {
return (
<li key={`crew-${person.id}-${index}`}>

View File

@@ -1,81 +1,42 @@
import React, { useContext } from 'react';
import useSWR, { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Header from '../Common/Header';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import type { MovieResult } from '../../../server/models/Search';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
recommendations: 'Recommendations',
recommendationssubtext: 'If you liked {title}, you might also like…',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const MovieRecommendations: React.FC = () => {
const settings = useSettings();
const intl = useIntl();
const router = useRouter();
const { locale } = useContext(LanguageContext);
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
const { data: movieData } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}`
);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/movie/${router.query.movieId}/recommendations?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult>(
`/api/v1/movie/${router.query.movieId}/recommendations`
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle
@@ -84,14 +45,12 @@ const MovieRecommendations: React.FC = () => {
<div className="mt-1 mb-5">
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.recommendationssubtext, {
title: movieData.title,
})
: ''
<Link href={`/movie/${movieData?.id}`}>
<a className="hover:underline">{movieData?.title}</a>
</Link>
}
>
<FormattedMessage {...messages.recommendations} />
{intl.formatMessage(messages.recommendations)}
</Header>
</div>
<ListView

View File

@@ -1,81 +1,40 @@
import React, { useContext } from 'react';
import useSWR, { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Header from '../Common/Header';
import { LanguageContext } from '../../context/LanguageContext';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { MediaStatus } from '../../../server/constants/media';
import useSettings from '../../hooks/useSettings';
import type { MovieResult } from '../../../server/models/Search';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
similar: 'Similar Titles',
similarsubtext: 'Other movies similar to {title}',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const MovieSimilar: React.FC = () => {
const settings = useSettings();
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
const { data: movieData } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}`
);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/movie/${router.query.movieId}/similar?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult>(`/api/v1/movie/${router.query.movieId}/similar`);
if (error) {
return <div>{error}</div>;
return <Error statusCode={500} />;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle
@@ -84,14 +43,12 @@ const MovieSimilar: React.FC = () => {
<div className="mt-1 mb-5">
<Header
subtext={
movieData && !movieError
? intl.formatMessage(messages.similarsubtext, {
title: movieData.title,
})
: undefined
<Link href={`/movie/${movieData?.id}`}>
<a className="hover:underline">{movieData?.title}</a>
</Link>
}
>
<FormattedMessage {...messages.similar} />
{intl.formatMessage(messages.similar)}
</Header>
</div>
<ListView

View File

@@ -1,47 +1,56 @@
import React, { useState, useContext, useMemo } from 'react';
import {
defineMessages,
FormattedNumber,
FormattedDate,
useIntl,
} from 'react-intl';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import useSWR from 'swr';
import { useRouter } from 'next/router';
import Button from '../Common/Button';
import Link from 'next/link';
import Slider from '../Slider';
import PersonCard from '../PersonCard';
import { LanguageContext } from '../../context/LanguageContext';
import LoadingSpinner from '../Common/LoadingSpinner';
import { useUser, Permission } from '../../hooks/useUser';
import { MediaStatus } from '../../../server/constants/media';
ArrowCircleRightIcon,
CogIcon,
FilmIcon,
PlayIcon,
} from '@heroicons/react/outline';
import {
CheckCircleIcon,
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
DocumentRemoveIcon,
ExternalLinkIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock';
import TmdbLogo from '../../assets/tmdb_logo.svg';
import RTFresh from '../../assets/rt_fresh.svg';
import RTRotten from '../../assets/rt_rotten.svg';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { MediaStatus } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
import PageTitle from '../Common/PageTitle';
import RTFresh from '../../assets/rt_fresh.svg';
import RTRotten from '../../assets/rt_rotten.svg';
import TmdbLogo from '../../assets/tmdb_logo.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import { sortCrewPriority } from '../../utils/creditHelpers';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import ConfirmButton from '../Common/ConfirmButton';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import { MediaServerType } from '../../../server/constants/server';
import SlideOver from '../Common/SlideOver';
import DownloadBlock from '../DownloadBlock';
import ExternalLinkBlock from '../ExternalLinkBlock';
import MediaSlider from '../MediaSlider';
import PersonCard from '../PersonCard';
import RequestBlock from '../RequestBlock';
import RequestButton from '../RequestButton';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
originaltitle: 'Original Title',
releasedate: 'Release Date',
userrating: 'User Rating',
status: 'Status',
revenue: 'Revenue',
budget: 'Budget',
watchtrailer: 'Watch Trailer',
@@ -51,30 +60,25 @@ const messages = defineMessages({
cast: 'Cast',
recommendations: 'Recommendations',
similar: 'Similar Titles',
cancelrequest: 'Cancel Request',
available: 'Available',
unavailable: 'Unavailable',
pending: 'Pending',
overviewunavailable: 'Overview unavailable.',
manageModalTitle: 'Manage Movie',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No Requests',
manageModalClearMedia: 'Clear All Media Data',
manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data',
manageModalClearMediaWarning:
'This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.',
approve: 'Approve',
decline: 'Decline',
studio: 'Studio',
'* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
studio: '{studioCount, plural, one {Studio} other {Studios}}',
viewfullcrew: 'View Full Crew',
view: 'View',
areyousure: 'Are you sure?',
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
play: 'Play on {mediaServerName}',
play4k: 'Play 4K on {mediaServerName}',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark 4K as Available',
mark4kavailable: 'Mark as Available in 4K',
showmore: 'Show More',
showless: 'Show Less',
streamingproviders: 'Currently Streaming On',
});
interface MovieDetailsProps {
@@ -86,11 +90,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const { user, hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { locale } = useLocale();
const [showManager, setShowManager] = useState(false);
const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
`/api/v1/movie/${router.query.movieId}`,
{
initialData: movie,
}
@@ -112,6 +118,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return <Error statusCode={404} />;
}
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
const mediaLinks: PlayButtonLink[] = [];
if (data.mediaInfo?.mediaUrl) {
@@ -121,6 +128,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />,
});
}
@@ -136,6 +144,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />,
});
}
@@ -148,6 +157,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
mediaLinks.push({
text: intl.formatMessage(messages.watchtrailer),
url: trailerUrl,
svg: <FilmIcon />,
});
}
@@ -194,17 +204,53 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}
if (data.genres.length) {
movieAttributes.push(data.genres.map((g) => g.name).join(', '));
movieAttributes.push(
data.genres
.map((g) => (
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
<a className="hover:underline">{g.name}</a>
</Link>
))
.reduce((prev, curr) => (
<>
{intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})}
</>
))
);
}
const streamingProviders =
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
?.flatrate ?? [];
return (
<div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
className="media-page"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}}
>
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.title} />
<SlideOver
show={showManager}
@@ -215,7 +261,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl">
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
@@ -253,18 +299,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
className="w-full sm:mb-0"
buttonType="success"
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
@@ -278,18 +313,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
className="w-full sm:mb-0"
buttonType="success"
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
clipRule="evenodd"
/>
</svg>
<CheckCircleIcon />
<span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
@@ -298,7 +322,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</div>
)}
<h3 className="mb-2 text-xl">
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
@@ -328,15 +352,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.openradarr)}</span>
</Button>
</a>
@@ -348,15 +364,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.openradarr4k)}</span>
</Button>
</a>
@@ -367,56 +375,40 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<div className="mt-8">
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(messages.areyousure)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
{intl.formatMessage(messages.manageModalClearMedia)}
<DocumentRemoveIcon />
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
</ConfirmButton>
<div className="mt-2 text-sm text-gray-400">
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
<div className="media-header">
<div className="media-poster">
<CachedImage
src={
data.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="ml-2 lg:ml-0">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
mediaUrl={data.mediaInfo?.mediaUrl}
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
/>
</span>
)}
<span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0}
mediaUrl={data.mediaInfo?.mediaUrl}
mediaUrl4k={
data.mediaInfo?.mediaUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
? data.mediaInfo.mediaUrl4k
: undefined
}
/>
</span>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
mediaUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -424,25 +416,25 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
type: 'or',
}
) && (
<span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
/>
</span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
<h1 className="text-2xl lg:text-4xl">
<h1>
{data.title}{' '}
{data.releaseDate && (
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
<span className="media-year">
({data.releaseDate.slice(0, 4)})
</span>
)}
</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0">
<span className="media-attributes">
{movieAttributes.length > 0 &&
movieAttributes
.map((t, k) => <span key={k}>{t}</span>)
@@ -453,113 +445,82 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
))}
</span>
</div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
<div className="mb-3 sm:mb-0">
<PlayButton links={mediaLinks} />
</div>
<div className="mb-3 sm:mb-0">
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="mb-3 ml-2 first:ml-0 sm:mb-0"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg
className="w-5"
style={{ height: 20 }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<CogIcon />
</Button>
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
<div className="media-overview">
<div className="media-overview-left">
{data.tagline && <div className="tagline">{data.tagline}</div>}
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
{sortedCrew.slice(0, 6).map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
{sortedCrew.length > 0 && (
<>
<ul className="media-crew">
{sortedCrew.slice(0, 6).map((person) => (
<li key={`crew-${person.job}-${person.id}`}>
<span>{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="crew-name">{person.name}</a>
</Link>
</li>
))}
</ul>
<div className="flex justify-end mt-4">
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<ArrowCircleRightIcon className="inline-block w-5 h-5 ml-1.5" />
</a>
</Link>
</li>
))}
</ul>
{sortedCrew.length > 0 && (
<div className="flex justify-end mt-4">
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<svg
className="inline-block w-5 h-5 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
</div>
</>
)}
</div>
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="media-overview-right">
{data.collection && (
<div className="mb-6">
<Link href={`/collection/${data.collection.id}`}>
<a>
<div
className="relative z-0 transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105"
style={{
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
}}
>
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
<div className="relative z-0 overflow-hidden transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105 ring-1 ring-gray-700 hover:ring-gray-500">
<div className="absolute inset-0 z-0">
<CachedImage
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
alt=""
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%)',
}}
/>
</div>
<div className="relative z-10 flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
<div>{data.collection.name}</div>
<Button buttonSize="sm">
{intl.formatMessage(messages.view)}
{intl.formatMessage(globalMessages.view)}
</Button>
</div>
</div>
@@ -567,165 +528,197 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</Link>
</div>
)}
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
<div className="media-facts">
{(!!data.voteCount ||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<>
<span className="text-sm">
<span className="media-rating">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<>
<span className="text-sm">
<span className="media-rating">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
{!!data.voteCount && (
<>
<span className="text-sm">
<span className="media-rating">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
)}
</div>
)}
{data.originalTitle &&
data.originalLanguage !== locale.slice(0, 2) && (
<div className="media-fact">
<span>{intl.formatMessage(messages.originaltitle)}</span>
<span className="media-fact-value">{data.originalTitle}</span>
</div>
)}
<div className="media-fact">
<span>{intl.formatMessage(globalMessages.status)}</span>
<span className="media-fact-value">{data.status}</span>
</div>
{data.releaseDate && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.releasedate)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.releaseDate)}
year="numeric"
month="long"
day="numeric"
/>
<div className="media-fact">
<span>{intl.formatMessage(messages.releasedate)}</span>
<span className="media-fact-value">
{intl.formatDate(data.releaseDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.status)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
{data.revenue > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.revenue)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
value={data.revenue}
/>
<div className="media-fact">
<span>{intl.formatMessage(messages.revenue)}</span>
<span className="media-fact-value">
{intl.formatNumber(data.revenue, {
currency: 'USD',
style: 'currency',
})}
</span>
</div>
)}
{data.budget > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.budget)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
value={data.budget}
/>
<div className="media-fact">
<span>{intl.formatMessage(messages.budget)}</span>
<span className="media-fact-value">
{intl.formatNumber(data.budget, {
currency: 'USD',
style: 'currency',
})}
</span>
</div>
)}
{data.spokenLanguages.some(
(lng) => lng.iso_639_1 === data.originalLanguage
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.originallanguage)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name
}
{data.originalLanguage && (
<div className="media-fact">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="media-fact-value">
<Link
href={`/discover/movies/language/${data.originalLanguage}`}
>
<a>
{intl.formatDisplayName(data.originalLanguage, {
type: 'language',
fallback: 'none',
}) ??
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name}
</a>
</Link>
</span>
</div>
)}
{data.productionCompanies[0] && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.studio)}
{data.productionCompanies.length > 0 && (
<div className="media-fact">
<span>
{intl.formatMessage(messages.studio, {
studioCount: data.productionCompanies.length,
})}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.productionCompanies[0]?.name}
<span className="media-fact-value">
{data.productionCompanies
.slice(
0,
showAllStudios || showMoreStudios
? data.productionCompanies.length
: minStudios
)
.map((s) => {
return (
<Link
href={`/discover/movies/studio/${s.id}`}
key={`studio-${s.id}`}
>
<a className="block">{s.name}</a>
</Link>
);
})}
{!showAllStudios && (
<button
type="button"
onClick={() => {
setShowMoreStudios(!showMoreStudios);
}}
>
<span className="flex items-center">
{intl.formatMessage(
!showMoreStudios
? messages.showmore
: messages.showless
)}
{!showMoreStudios ? (
<ChevronDoubleDownIcon className="w-4 h-4 ml-1" />
) : (
<ChevronDoubleUpIcon className="w-4 h-4 ml-1" />
)}
</span>
</button>
)}
</span>
</div>
)}
</div>
<div className="mt-4">
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
mediaUrl={data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k}
/>
{!!streamingProviders.length && (
<div className="media-fact">
<span>{intl.formatMessage(messages.streamingproviders)}</span>
<span className="media-fact-value">
{streamingProviders.map((p) => {
return (
<span className="block" key={`provider-${p.id}`}>
{p.name}
</span>
);
})}
</span>
</div>
)}
<div className="media-fact">
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/>
</div>
</div>
</div>
</div>
{data.credits.cast.length > 0 && (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.cast)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<div className="slider-header">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.cast)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="cast"

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { NotificationItem, hasNotificationType } from '..';
import { hasNotificationType, NotificationItem } from '..';
interface NotificationTypeProps {
option: NotificationItem;
@@ -38,7 +38,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
: currentTypes + option.value
);
}}
defaultChecked={
checked={
hasNotificationType(option.value, currentTypes) ||
(!!parent?.value &&
hasNotificationType(parent.value, currentTypes))
@@ -46,10 +46,14 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="font-medium">
{option.name}
<label htmlFor={option.id} className="block">
<div className="flex flex-col">
<span className="font-medium text-white">{option.name}</span>
<span className="font-normal text-gray-400">
{option.description}
</span>
</div>
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (

View File

@@ -1,23 +1,42 @@
import React from 'react';
import { sortBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import { Permission, User, useUser } from '../../hooks/useUser';
import NotificationType from './NotificationType';
const messages = defineMessages({
notificationTypes: 'Notification Types',
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when media is requested and requires approval.',
'Send notifications when users submit new media requests which require approval.',
usermediarequestedDescription:
'Get notified when other users submit new media requests which require approval.',
mediaapproved: 'Media Approved',
mediaapprovedDescription:
'Sends a notification when media is approved.\
By default, automatically approved requests will not trigger notifications.',
'Send notifications when media requests are manually approved.',
usermediaapprovedDescription:
'Get notified when your media requests are approved.',
mediaAutoApproved: 'Media Automatically Approved',
mediaAutoApprovedDescription:
'Send notifications when users submit new media requests which are automatically approved.',
usermediaAutoApprovedDescription:
'Get notified when other users submit new media requests which are automatically approved.',
mediaavailable: 'Media Available',
mediaavailableDescription:
'Sends a notification when media becomes available.',
'Send notifications when media requests become available.',
usermediaavailableDescription:
'Get notified when your media requests become available.',
mediafailed: 'Media Failed',
mediafailedDescription:
'Sends a notification when media fails to be added to Radarr or Sonarr.',
'Send notifications when media requests fail to be added to Radarr or Sonarr.',
usermediafailedDescription:
'Get notified when media requests fail to be added to Radarr or Sonarr.',
mediadeclined: 'Media Declined',
mediadeclinedDescription: 'Sends a notification when a request is declined.',
mediadeclinedDescription:
'Send notifications when media requests are declined.',
usermediadeclinedDescription:
'Get notified when your media requests are declined.',
});
export const hasNotificationType = (
@@ -26,91 +45,235 @@ export const hasNotificationType = (
): boolean => {
let total = 0;
// If we are not checking any notifications, bail out and return true
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
// Combine all notification values into one
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
export enum Notification {
NONE = 0,
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
}
export const ALL_NOTIFICATIONS = Object.values(Notification)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
export interface NotificationItem {
id: string;
name: string;
description: string;
value: Notification;
hasNotifyUser?: boolean;
children?: NotificationItem[];
hidden?: boolean;
}
interface NotificationTypeSelectorProps {
user?: User;
enabledTypes?: number;
currentTypes: number;
onUpdate: (newTypes: number) => void;
error?: string;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
user,
enabledTypes = ALL_NOTIFICATIONS,
currentTypes,
onUpdate,
error,
}) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser({ id: user?.id });
const [allowedTypes, setAllowedTypes] = useState(enabledTypes);
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(messages.mediarequestedDescription),
value: Notification.MEDIA_PENDING,
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(messages.mediaapprovedDescription),
value: Notification.MEDIA_APPROVED,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(messages.mediadeclinedDescription),
value: Notification.MEDIA_DECLINED,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(messages.mediaavailableDescription),
value: Notification.MEDIA_AVAILABLE,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(messages.mediafailedDescription),
value: Notification.MEDIA_FAILED,
},
];
const availableTypes = useMemo(() => {
const allRequestsAutoApproved =
user &&
// Has Manage Requests perm, which grants all Auto-Approve perms
(hasPermission(Permission.MANAGE_REQUESTS) ||
// Cannot submit requests of any type
!hasPermission(
[
Permission.REQUEST,
Permission.REQUEST_MOVIE,
Permission.REQUEST_TV,
Permission.REQUEST_4K,
Permission.REQUEST_4K_MOVIE,
Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) ||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
{ type: 'or' }
)) &&
// Cannot submit 4K movie requests OR has Auto-Approve perms for 4K movies
(!settings.currentSettings.movie4kEnabled ||
!hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit 4K series requests OR has Auto-Approve perms for 4K series
(!settings.currentSettings.series4kEnabled ||
!hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_TV],
{ type: 'or' }
))));
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(
user
? messages.usermediarequestedDescription
: messages.mediarequestedDescription
),
value: Notification.MEDIA_PENDING,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-auto-approved',
name: intl.formatMessage(messages.mediaAutoApproved),
description: intl.formatMessage(
user
? messages.usermediaAutoApprovedDescription
: messages.mediaAutoApprovedDescription
),
value: Notification.MEDIA_AUTO_APPROVED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(
user
? messages.usermediaapprovedDescription
: messages.mediaapprovedDescription
),
value: Notification.MEDIA_APPROVED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(
user
? messages.usermediadeclinedDescription
: messages.mediadeclinedDescription
),
value: Notification.MEDIA_DECLINED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(
user
? messages.usermediaavailableDescription
: messages.mediaavailableDescription
),
value: Notification.MEDIA_AVAILABLE,
hasNotifyUser: true,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(
user
? messages.usermediafailedDescription
: messages.mediafailedDescription
),
value: Notification.MEDIA_FAILED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
];
const filteredTypes = types.filter(
(type) => !type.hidden && hasNotificationType(type.value, enabledTypes)
);
const newAllowedTypes = filteredTypes.reduce((a, v) => a + v.value, 0);
if (newAllowedTypes !== allowedTypes) {
setAllowedTypes(newAllowedTypes);
}
return user
? sortBy(filteredTypes, 'hasNotifyUser', 'DESC')
: filteredTypes;
}, [user, hasPermission, settings, intl, allowedTypes, enabledTypes]);
if (!availableTypes.length) {
return null;
}
return (
<>
{types.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
currentTypes={currentTypes}
onUpdate={onUpdate}
/>
))}
</>
<div role="group" aria-labelledby="group-label" className="form-group">
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationTypes)}
{!user && <span className="label-required">*</span>}
</span>
<div className="form-input">
<div className="max-w-lg">
{availableTypes.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
currentTypes={currentTypes}
onUpdate={onUpdate}
/>
))}
</div>
{error && <div className="error">{error}</div>}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,183 @@
import React from 'react';
interface PWAHeaderProps {
applicationTitle?: string;
}
const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
return (
<>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link
rel="manifest"
href="/site.webmanifest"
crossOrigin="use-credentials"
/>
<meta name="application-name" content={applicationTitle ?? 'Overseerr'} />
<meta
name="apple-mobile-web-app-title"
content={applicationTitle ?? 'Overseerr'}
/>
<meta
name="description"
content="Request and Media Discovery Application"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#1f2937" />
</>
);
};
export default PWAHeader;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PermissionOption, { PermissionItem } from '../PermissionOption';
import { defineMessages, useIntl } from 'react-intl';
import { Permission, User } from '../../hooks/useUser';
import { useIntl, defineMessages } from 'react-intl';
import PermissionOption, { PermissionItem } from '../PermissionOption';
export const messages = defineMessages({
admin: 'Admin',
@@ -9,47 +9,46 @@ export const messages = defineMessages({
'Full administrator access. Bypasses all other permission checks.',
users: 'Manage Users',
usersDescription:
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
settings: 'Manage Settings',
settingsDescription:
'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests',
managerequestsDescription:
'Grants permission to manage Overseerr requests. This includes approving and denying requests. All requests made by a user with this permission will be automatically approved regardless of whether or not they have Auto-Approve permissions.',
'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.',
request: 'Request',
requestDescription: 'Grants permission to request movies and series.',
vote: 'Vote',
voteDescription:
'Grants permission to vote on requests (voting not yet implemented).',
requestDescription: 'Grant permission to request non-4K media.',
requestMovies: 'Request Movies',
requestMoviesDescription: 'Grant permission to request non-4K movies.',
requestTv: 'Request Series',
requestTvDescription: 'Grant permission to request non-4K series.',
autoapprove: 'Auto-Approve',
autoapproveDescription:
'Grants automatic approval for all non-4K requests made by this user.',
autoapproveDescription: 'Grant automatic approval for all non-4K requests.',
autoapproveMovies: 'Auto-Approve Movies',
autoapproveMoviesDescription:
'Grants automatic approval for non-4K movie requests made by this user.',
'Grant automatic approval for non-4K movie requests.',
autoapproveSeries: 'Auto-Approve Series',
autoapproveSeriesDescription:
'Grants automatic approval for non-4K series requests made by this user.',
'Grant automatic approval for non-4K series requests.',
autoapprove4k: 'Auto-Approve 4K',
autoapprove4kDescription:
'Grants automatic approval for all 4K requests made by this user.',
autoapprove4kDescription: 'Grant automatic approval for all 4K requests.',
autoapprove4kMovies: 'Auto-Approve 4K Movies',
autoapprove4kMoviesDescription:
'Grants automatic approval for 4K movie requests made by this user.',
'Grant automatic approval for 4K movie requests.',
autoapprove4kSeries: 'Auto-Approve 4K Series',
autoapprove4kSeriesDescription:
'Grants automatic approval for 4K series requests made by this user.',
'Grant automatic approval for 4K series requests.',
request4k: 'Request 4K',
request4kDescription: 'Grants permission to request 4K movies and series.',
request4kDescription: 'Grant permission to request 4K media.',
request4kMovies: 'Request 4K Movies',
request4kMoviesDescription: 'Grants permission to request 4K movies.',
request4kMoviesDescription: 'Grant permission to request 4K movies.',
request4kTv: 'Request 4K Series',
request4kTvDescription: 'Grants permission to request 4K Series.',
request4kTvDescription: 'Grant permission to request 4K series.',
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grants permission to use advanced request options (e.g., changing servers, profiles, or paths).',
'Grant permission to use advanced request options.',
viewrequests: 'View Requests',
viewrequestsDescription: "Grants permission to view other users' requests.",
viewrequestsDescription: "Grant permission to view other users' requests.",
});
interface PermissionEditProps {
@@ -111,27 +110,18 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
name: intl.formatMessage(messages.request),
description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
requires: [{ permissions: [Permission.REQUEST] }],
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(messages.request4kMovies),
description: intl.formatMessage(messages.request4kMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE,
requires: [{ permissions: [Permission.REQUEST] }],
id: 'request-movies',
name: intl.formatMessage(messages.requestMovies),
description: intl.formatMessage(messages.requestMoviesDescription),
permission: Permission.REQUEST_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(messages.request4kTv),
description: intl.formatMessage(messages.request4kTvDescription),
permission: Permission.REQUEST_4K_TV,
requires: [{ permissions: [Permission.REQUEST] }],
id: 'request-tv',
name: intl.formatMessage(messages.requestTv),
description: intl.formatMessage(messages.requestTvDescription),
permission: Permission.REQUEST_TV,
},
],
},
@@ -149,7 +139,12 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
messages.autoapproveMoviesDescription
),
permission: Permission.AUTO_APPROVE_MOVIE,
requires: [{ permissions: [Permission.REQUEST] }],
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or',
},
],
},
{
id: 'autoapprovetv',
@@ -158,7 +153,32 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
messages.autoapproveSeriesDescription
),
permission: Permission.AUTO_APPROVE_TV,
requires: [{ permissions: [Permission.REQUEST] }],
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
type: 'or',
},
],
},
],
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(messages.request4kMovies),
description: intl.formatMessage(messages.request4kMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(messages.request4kTv),
description: intl.formatMessage(messages.request4kTvDescription),
permission: Permission.REQUEST_4K_TV,
},
],
},
@@ -169,8 +189,7 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
permission: Permission.AUTO_APPROVE_4K,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_4K],
type: 'and',
permissions: [Permission.REQUEST_4K],
},
],
children: [
@@ -182,9 +201,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
),
permission: Permission.AUTO_APPROVE_4K_MOVIE,
requires: [
{
permissions: [Permission.REQUEST],
},
{
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
type: 'or',
@@ -199,9 +215,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
),
permission: Permission.AUTO_APPROVE_4K_TV,
requires: [
{
permissions: [Permission.REQUEST],
},
{
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV],
type: 'or',

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { hasPermission } from '../../../server/lib/permissions';
import useSettings from '../../hooks/useSettings';
import { Permission, User } from '../../hooks/useUser';
export interface PermissionItem {
@@ -33,6 +34,8 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
onUpdate,
parent,
}) => {
const settings = useSettings();
const autoApprovePermissions = [
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MOVIE,
@@ -42,34 +45,70 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
Permission.AUTO_APPROVE_4K_TV,
];
let disabled = false;
let checked = hasPermission(option.permission, currentPermission);
if (
// Permissions for user ID 1 (Plex server owner) cannot be changed
(currentUser && currentUser.id === 1) ||
// Admin permission automatically bypasses/grants all other permissions
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
// Manage Requests permission automatically grants all Auto-Approve permissions
(autoApprovePermissions.includes(option.permission) &&
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
// Selecting a parent permission automatically selects all children
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission))
) {
disabled = true;
checked = true;
}
if (
// Non-Admin users cannot modify the Admin permission
(actingUser &&
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
option.permission === Permission.ADMIN) ||
// Users without the Manage Settings permission cannot modify/grant that permission
(actingUser &&
!hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) &&
option.permission === Permission.MANAGE_SETTINGS)
) {
disabled = true;
}
if (
// Some permissions are dependent on others; check requirements are fulfilled
(option.requires &&
!option.requires.every((requirement) =>
hasPermission(requirement.permissions, currentPermission, {
type: requirement.type ?? 'and',
})
)) ||
// Request 4K and Auto-Approve 4K require both 4K movie & 4K series requests to be enabled
((option.permission === Permission.REQUEST_4K ||
option.permission === Permission.AUTO_APPROVE_4K) &&
(!settings.currentSettings.movie4kEnabled ||
!settings.currentSettings.series4kEnabled)) ||
// Request 4K Movie and Auto-Approve 4K Movie require 4K movie requests to be enabled
((option.permission === Permission.REQUEST_4K_MOVIE ||
option.permission === Permission.AUTO_APPROVE_4K_MOVIE) &&
!settings.currentSettings.movie4kEnabled) ||
// Request 4K Series and Auto-Approve 4K Series require 4K series requests to be enabled
((option.permission === Permission.REQUEST_4K_TV ||
option.permission === Permission.AUTO_APPROVE_4K_TV) &&
!settings.currentSettings.series4kEnabled)
) {
disabled = true;
checked = false;
}
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
(currentUser && currentUser.id === 1) ||
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
(autoApprovePermissions.includes(option.permission) &&
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission)) ||
(actingUser &&
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
option.permission === Permission.ADMIN) ||
(actingUser &&
!hasPermission(
Permission.MANAGE_SETTINGS,
actingUser.permissions
) &&
option.permission === Permission.MANAGE_SETTINGS) ||
(option.requires &&
!option.requires.every((requirement) =>
hasPermission(requirement.permissions, currentPermission, {
type: requirement.type ?? 'and',
})
))
? 'opacity-50'
: ''
disabled ? 'opacity-50' : ''
}`}
>
<div className="flex items-center h-6">
@@ -77,30 +116,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
id={option.id}
name="permissions"
type="checkbox"
disabled={
(currentUser && currentUser.id === 1) ||
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
(autoApprovePermissions.includes(option.permission) &&
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission)) ||
(actingUser &&
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
option.permission === Permission.ADMIN) ||
(actingUser &&
!hasPermission(
Permission.MANAGE_SETTINGS,
actingUser.permissions
) &&
option.permission === Permission.MANAGE_SETTINGS) ||
(option.requires &&
!option.requires.every((requirement) =>
hasPermission(requirement.permissions, currentPermission, {
type: requirement.type ?? 'and',
})
))
}
disabled={disabled}
onChange={() => {
onUpdate(
hasPermission(option.permission, currentPermission)
@@ -108,29 +124,16 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
: currentPermission + option.permission
);
}}
checked={
(hasPermission(option.permission, currentPermission) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission)) ||
(autoApprovePermissions.includes(option.permission) &&
hasPermission(
Permission.MANAGE_REQUESTS,
currentPermission
))) &&
(!option.requires ||
option.requires.every((requirement) =>
hasPermission(requirement.permissions, currentPermission, {
type: requirement.type ?? 'and',
})
))
}
checked={checked}
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="block font-medium">
<label htmlFor={option.id} className="block">
<div className="flex flex-col">
<span>{option.name}</span>
<span className="text-gray-500">{option.description}</span>
<span className="font-medium text-white">{option.name}</span>
<span className="font-normal text-gray-400">
{option.description}
</span>
</div>
</label>
</div>

View File

@@ -1,5 +1,7 @@
import { UserCircleIcon } from '@heroicons/react/solid';
import Link from 'next/link';
import React, { useState } from 'react';
import CachedImage from '../Common/CachedImage';
interface PersonCardProps {
personId: number;
@@ -37,35 +39,31 @@ const PersonCard: React.FC<PersonCardProps> = ({
<div
className={`relative ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
} rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${
isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100'
} rounded-xl text-white shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'
}`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
{profilePath ? (
<img
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
alt=""
/>
) : (
<svg
className="h-full"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
clipRule="evenodd"
<div className="relative w-3/4 h-full overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt=""
layout="fill"
objectFit="cover"
/>
</svg>
</div>
) : (
<UserCircleIcon className="h-full" />
)}
</div>
<div className="w-full text-center truncate">{name}</div>
<div className="w-full font-bold text-center truncate">
{name}
</div>
{subName && (
<div
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
@@ -79,7 +77,11 @@ const PersonCard: React.FC<PersonCardProps> = ({
{subName}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-700" />
<div
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</div>
</div>
</div>

View File

@@ -1,29 +1,31 @@
import { groupBy } from 'lodash';
import { useRouter } from 'next/router';
import React, { useContext, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import type { PersonDetail } from '../../../server/models/Person';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
import Error from '../../pages/_error';
import LoadingSpinner from '../Common/LoadingSpinner';
import TitleCard from '../TitleCard';
import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import ImageFader from '../Common/ImageFader';
import type { PersonDetail } from '../../../server/models/Person';
import Ellipsis from '../../assets/ellipsis.svg';
import { groupBy } from 'lodash';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import CachedImage from '../Common/CachedImage';
import ImageFader from '../Common/ImageFader';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import TitleCard from '../TitleCard';
const messages = defineMessages({
appearsin: 'Appears in',
crewmember: 'Crew Member',
birthdate: 'Born {birthdate}',
lifespan: '{birthdate} {deathdate}',
alsoknownas: 'Also Known As: {names}',
appearsin: 'Appearances',
crewmember: 'Crew',
ascharacter: 'as {character}',
nobiography: 'No biography available.',
});
const PersonDetails: React.FC = () => {
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const router = useRouter();
const { data, error } = useSWR<PersonDetail>(
`/api/v1/person/${router.query.personId}`
@@ -34,7 +36,7 @@ const PersonDetails: React.FC = () => {
data: combinedCredits,
error: errorCombinedCredits,
} = useSWR<PersonCombinedCreditsResponse>(
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
`/api/v1/person/${router.query.personId}/combined_credits`
);
const sortedCast = useMemo(() => {
@@ -81,18 +83,51 @@ const PersonDetails: React.FC = () => {
return <Error statusCode={404} />;
}
const personAttributes: string[] = [];
if (data.birthday) {
if (data.deathday) {
personAttributes.push(
intl.formatMessage(messages.lifespan, {
birthdate: intl.formatDate(data.birthday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
deathdate: intl.formatDate(data.deathday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})
);
} else {
personAttributes.push(
intl.formatMessage(messages.birthdate, {
birthdate: intl.formatDate(data.birthday, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})
);
}
}
if (data.placeOfBirth) {
personAttributes.push(data.placeOfBirth);
}
const isLoading = !combinedCredits && !errorCombinedCredits;
const cast = (sortedCast ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
<ul className="cardList">
<ul className="cards-vertical">
{sortedCast?.map((media, index) => {
return (
<li key={`list-cast-item-${media.id}-${index}`}>
@@ -112,7 +147,7 @@ const PersonDetails: React.FC = () => {
canExpand
/>
{media.character && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
@@ -127,14 +162,12 @@ const PersonDetails: React.FC = () => {
const crew = (sortedCrew ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
<ul className="cardList">
<ul className="cards-vertical">
{sortedCrew?.map((media, index) => {
return (
<li key={`list-crew-item-${media.id}-${index}`}>
@@ -154,7 +187,7 @@ const PersonDetails: React.FC = () => {
canExpand
/>
{media.job && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
{media.job}
</div>
)}
@@ -169,52 +202,71 @@ const PersonDetails: React.FC = () => {
<>
<PageTitle title={data.name} />
{(sortedCrew || sortedCast) && (
<div className="absolute left-0 right-0 z-0 -top-16 h-96">
<div className="absolute top-0 left-0 right-0 z-0 h-96">
<ImageFader
isDarker
backgroundImages={[...(sortedCast ?? []), ...(sortedCrew ?? [])]
.filter((media) => media.backdropPath)
.map(
(media) =>
`//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
)
.slice(0, 6)}
/>
</div>
)}
<div className="relative z-10 flex flex-col items-center mt-4 mb-8 md:flex-row md:items-start">
<div
className={`relative z-10 flex flex-col items-center mt-4 mb-8 lg:flex-row ${
data.biography ? 'lg:items-start' : ''
}`}
>
{data.profilePath && (
<div
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
}}
className="flex-shrink-0 mb-6 mr-0 bg-center bg-cover rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6"
/>
)}
<div className="text-center text-gray-300 md:text-left">
<h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1>
<div className="relative">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="outline-none group ring-0"
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
>
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative inline-block ml-2 -top-0.5 opacity-70 group-hover:opacity-100 transition duration-300" />
}
>
<div>
{data.biography
? data.biography
: intl.formatMessage(messages.nobiography)}
</div>
</TruncateMarkup>
</div>
<div className="relative flex-shrink-0 mb-6 mr-0 overflow-hidden rounded-full w-36 h-36 lg:w-44 lg:h-44 lg:mb-0 lg:mr-6 ring-1 ring-gray-700">
<CachedImage
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
layout="fill"
objectFit="cover"
/>
</div>
)}
<div className="text-center text-gray-300 lg:text-left">
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
<div>{personAttributes.join(' | ')}</div>
{(data.alsoKnownAs ?? []).length > 0 && (
<div>
{intl.formatMessage(messages.alsoknownas, {
names: (data.alsoKnownAs ?? []).reduce((prev, curr) =>
intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})
),
})}
</div>
)}
</div>
{data.biography && (
<div className="relative text-left">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="outline-none group ring-0"
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
>
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative inline-block ml-2 -top-0.5 opacity-70 group-hover:opacity-100 transition duration-300" />
}
>
<p className="pt-2 text-sm lg:text-base">{data.biography}</p>
</TruncateMarkup>
</div>
</div>
)}
</div>
</div>
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}

View File

@@ -1,11 +1,12 @@
import { LoginIcon } from '@heroicons/react/outline';
import React, { useState } from 'react';
import PlexOAuth from '../../utils/plex';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
import PlexOAuth from '../../utils/plex';
const messages = defineMessages({
signinwithplex: 'Sign In',
loading: 'Loading…',
signingin: 'Signing in…',
signingin: 'Signing In…',
});
const plexOAuth = new PlexOAuth();
@@ -48,11 +49,14 @@ const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
disabled={loading || isProcessing}
className="plex-button"
>
{loading
? intl.formatMessage(messages.loading)
: isProcessing
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signinwithplex)}
<LoginIcon />
<span>
{loading
? intl.formatMessage(globalMessages.loading)
: isProcessing
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signinwithplex)}
</span>
</button>
</span>
);

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
movieRequests:
'{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>',
tvRequests:
'{quotaLimit} <quotaUnits>{seasons} per {quotaDays} {days}</quotaUnits>',
movies: '{count, plural, one {movie} other {movies}}',
seasons: '{count, plural, one {season} other {seasons}}',
days: '{count, plural, one {day} other {days}}',
unlimited: 'Unlimited',
});
interface QuotaSelectorProps {
mediaType: 'movie' | 'tv';
defaultDays?: number;
defaultLimit?: number;
dayOverride?: number;
limitOverride?: number;
dayFieldName: string;
limitFieldName: string;
isDisabled?: boolean;
onChange: (fieldName: string, value: number) => void;
}
const QuotaSelector: React.FC<QuotaSelectorProps> = ({
mediaType,
dayFieldName,
limitFieldName,
defaultDays = 7,
defaultLimit = 0,
dayOverride,
limitOverride,
isDisabled = false,
onChange,
}) => {
const initialDays = defaultDays ?? 7;
const initialLimit = defaultLimit ?? 0;
const [quotaDays, setQuotaDays] = useState(initialDays);
const [quotaLimit, setQuotaLimit] = useState(initialLimit);
const intl = useIntl();
useEffect(() => {
onChange(dayFieldName, quotaDays);
}, [dayFieldName, onChange, quotaDays]);
useEffect(() => {
onChange(limitFieldName, quotaLimit);
}, [limitFieldName, onChange, quotaLimit]);
return (
<div className={`${isDisabled ? 'opacity-50' : ''}`}>
{intl.formatMessage(
mediaType === 'movie' ? messages.movieRequests : messages.tvRequests,
{
quotaLimit: (
<select
className="inline short"
value={limitOverride ?? quotaLimit}
onChange={(e) => setQuotaLimit(Number(e.target.value))}
disabled={isDisabled}
>
<option value="0">
{intl.formatMessage(messages.unlimited)}
</option>
{[...Array(100)].map((_item, i) => (
<option value={i + 1} key={`${mediaType}-limit-${i + 1}`}>
{i + 1}
</option>
))}
</select>
),
quotaDays: (
<select
className="inline short"
value={dayOverride ?? quotaDays}
onChange={(e) => setQuotaDays(Number(e.target.value))}
disabled={isDisabled}
>
{[...Array(100)].map((_item, i) => (
<option value={i + 1} key={`${mediaType}-days-${i + 1}`}>
{i + 1}
</option>
))}
</select>
),
movies: intl.formatMessage(messages.movies, { count: quotaLimit }),
seasons: intl.formatMessage(messages.seasons, { count: quotaLimit }),
days: intl.formatMessage(messages.days, { count: quotaDays }),
quotaUnits: function quotaUnits(msg) {
return (
<span className={limitOverride || quotaLimit ? '' : 'hidden'}>
{msg}
</span>
);
},
}
)}
</div>
);
};
export default React.memo(QuotaSelector);

View File

@@ -1,14 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { countryCodeEmoji } from 'country-code-emoji';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { hasFlag } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { sortBy } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { Region } from '../../../server/lib/settings';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
regionDefault: 'All Regions',
regionServerDefault: '{applicationTitle} Default ({region})',
regionServerDefault: 'Default ({region})',
});
interface RegionSelectorProps {
@@ -37,6 +40,22 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
[]
);
const sortedRegions = useMemo(() => {
regions?.forEach((region) => {
region.name =
intl.formatDisplayName(region.iso_3166_1, {
type: 'region',
fallback: 'none',
}) ?? region.english_name;
});
return sortBy(regions, 'name');
}, [intl, regions]);
const regionName = (regionCode: string) =>
sortedRegions?.find((region) => region.iso_3166_1 === regionCode)?.name ??
regionCode;
useEffect(() => {
if (regions && value) {
if (value === 'all') {
@@ -57,132 +76,82 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
}, [onChange, selectedRegion, name, regions]);
return (
<div className="relative z-40 flex max-w-lg">
<div className="w-full">
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
{({ open }) => (
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
{selectedRegion && selectedRegion.iso_3166_1 !== 'all' && (
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
{countryCodeEmoji(selectedRegion.iso_3166_1)}
</span>
)}
<span className="block truncate">
{selectedRegion && selectedRegion.iso_3166_1 !== 'all'
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
type: 'region',
fallback: 'none',
}) ?? selectedRegion.english_name
: isUserSetting && selectedRegion?.iso_3166_1 !== 'all'
? intl.formatMessage(messages.regionServerDefault, {
applicationTitle: currentSettings.applicationTitle,
region: currentSettings.region
? intl.formatDisplayName(currentSettings.region, {
type: 'region',
fallback: 'none',
}) ?? currentSettings.region
: intl.formatMessage(messages.regionDefault),
})
: intl.formatMessage(messages.regionDefault)}
<div className="z-40 w-full">
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
{({ open }) => (
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
{((selectedRegion && hasFlag(selectedRegion?.iso_3166_1)) ||
(isUserSetting &&
!selectedRegion &&
currentSettings.region &&
hasFlag(currentSettings.region))) && (
<span className="h-4 mr-2 overflow-hidden text-base leading-4">
<span
className={`flag:${
selectedRegion
? selectedRegion.iso_3166_1
: currentSettings.region
}`}
/>
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
className="w-5 h-5 text-gray-500"
>
<path
stroke="#6b7280"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M6 8l4 4 4-4"
/>
</svg>
</span>
</Listbox.Button>
</span>
)}
<span className="block truncate">
{selectedRegion && selectedRegion.iso_3166_1 !== 'all'
? regionName(selectedRegion.iso_3166_1)
: isUserSetting && selectedRegion?.iso_3166_1 !== 'all'
? intl.formatMessage(messages.regionServerDefault, {
region: currentSettings.region
? regionName(currentSettings.region)
: intl.formatMessage(messages.regionDefault),
})
: intl.formatMessage(messages.regionDefault)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
</span>
</Listbox.Button>
</span>
<Transition
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
<Transition
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
{isUserSetting && (
<Listbox.Option value={null}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatMessage(messages.regionServerDefault, {
applicationTitle:
currentSettings.applicationTitle,
region: currentSettings.region
? intl.formatDisplayName(
currentSettings.region,
{
type: 'region',
fallback: 'none',
}
) ?? currentSettings.region
: intl.formatMessage(messages.regionDefault),
})}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
)}
<Listbox.Option value={isUserSetting ? allRegion : null}>
{isUserSetting && (
<Listbox.Option value={null}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
>
<span className="mr-2 text-base">
<span
className={
hasFlag(currentSettings.region)
? `flag:${currentSettings.region}`
: 'pr-6'
}
/>
</span>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatMessage(messages.regionDefault)}
{intl.formatMessage(messages.regionServerDefault, {
region: currentSettings.region
? regionName(currentSettings.region)
: intl.formatMessage(messages.regionDefault),
})}
</span>
{selected && (
<span
@@ -190,76 +159,81 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<CheckIcon className="w-5 h-5" />
</span>
)}
</div>
)}
</Listbox.Option>
{regions?.map((region) => (
<Listbox.Option key={region.iso_3166_1} value={region}>
{({ selected, active }) => (
<div
)}
<Listbox.Option value={isUserSetting ? allRegion : null}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate pl-8`}
>
{intl.formatMessage(messages.regionDefault)}
</span>
{selected && (
<span
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<span className="mr-2 text-lg">
{countryCodeEmoji(region.iso_3166_1)}
</span>
<CheckIcon className="w-5 h-5" />
</span>
)}
</div>
)}
</Listbox.Option>
{sortedRegions?.map((region) => (
<Listbox.Option key={region.iso_3166_1} value={region}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
>
<span className="mr-2 text-base">
<span
className={
hasFlag(region.iso_3166_1)
? `flag:${region.iso_3166_1}`
: 'pr-6'
}
/>
</span>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{regionName(region.iso_3166_1)}
</span>
{selected && (
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
{intl.formatDisplayName(region.iso_3166_1, {
type: 'region',
fallback: 'none',
}) ?? region.english_name}
<CheckIcon className="w-5 h-5" />
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
</div>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
</div>
);
};

View File

@@ -1,19 +1,29 @@
import React, { useState } from 'react';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import { FormattedDate, useIntl, defineMessages } from 'react-intl';
import Badge from '../Common/Badge';
import { MediaRequestStatus } from '../../../server/constants/media';
import Button from '../Common/Button';
import {
CalendarIcon,
CheckIcon,
EyeIcon,
PencilIcon,
TrashIcon,
UserIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import globalMessages from '../../i18n/globalMessages';
import RequestModal from '../RequestModal';
import Link from 'next/link';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { MediaRequestStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import useRequestOverride from '../../hooks/useRequestOverride';
import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import RequestModal from '../RequestModal';
const messages = defineMessages({
seasons: 'Seasons',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
requestoverrides: 'Request Overrides',
server: 'Server',
profilechanged: 'Profile Changed',
server: 'Destination Server',
profilechanged: 'Quality Profile',
rootfolder: 'Root Folder',
});
@@ -65,43 +75,28 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
setShowEditModal(false);
}}
/>
<div className="px-4 py-4">
<div className="px-4 py-4 text-gray-300">
<div className="flex items-center justify-between">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
<div className="flex mb-1 flex-nowrap white">
<svg
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto">
{request.requestedBy.displayName}
<Link href={`/users/${request.requestedBy.id}`}>
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
{request.requestedBy.displayName}
</a>
</Link>
</span>
</div>
{request.modifiedBy && (
<div className="flex flex-nowrap">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path
fillRule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clipRule="evenodd"
/>
</svg>
<EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto">
{request.modifiedBy?.displayName}
<Link href={`/users/${request.modifiedBy.id}`}>
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
{request.modifiedBy.displayName}
</a>
</Link>
</span>
</div>
)}
@@ -109,62 +104,29 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="flex flex-wrap flex-shrink-0 ml-2">
{request.status === MediaRequestStatus.PENDING && (
<>
<span className="mr-1">
<Button
buttonType="success"
onClick={() => updateRequest('approve')}
disabled={isUpdating}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</Button>
</span>
<span className="mr-1">
<Button
buttonType="danger"
onClick={() => updateRequest('decline')}
disabled={isUpdating}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Button>
</span>
<span>
<Button
buttonType="primary"
onClick={() => setShowEditModal(true)}
disabled={isUpdating}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</Button>
</span>
<Button
buttonType="success"
className="mr-1"
onClick={() => updateRequest('approve')}
disabled={isUpdating}
>
<CheckIcon className="icon-sm" />
</Button>
<Button
buttonType="danger"
className="mr-1"
onClick={() => updateRequest('decline')}
disabled={isUpdating}
>
<XIcon />
</Button>
<Button
buttonType="primary"
onClick={() => setShowEditModal(true)}
disabled={isUpdating}
>
<PencilIcon className="icon-sm" />
</Button>
</>
)}
{request.status !== MediaRequestStatus.PENDING && (
@@ -173,25 +135,14 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
onClick={() => deleteRequest()}
disabled={isUpdating}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<TrashIcon className="icon-sm" />
</Button>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
<div className="flex items-center mr-6 text-sm leading-5">
{request.is4k && (
<span className="mr-1">
<Badge badgeType="warning">4K</Badge>
@@ -214,27 +165,24 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
)}
</div>
</div>
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clipRule="evenodd"
/>
</svg>
<div className="flex items-center mt-2 text-sm leading-5 sm:mt-0">
<CalendarIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<span>
<FormattedDate value={request.createdAt} />
{intl.formatDate(request.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
{(request.seasons ?? []).length > 0 && (
<div className="flex flex-col mt-2 text-sm">
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
<div className="mb-1 font-medium">
{intl.formatMessage(messages.seasons, {
seasonCount: request.seasons.length,
})}
</div>
<div>
{request.seasons.map((season) => (
<span
@@ -247,7 +195,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
</div>
)}
{(server || profile || rootFolder) && (
{(server || profile !== null || rootFolder) && (
<>
<div className="mt-4 mb-1 text-sm">
{intl.formatMessage(messages.requestoverrides)}

View File

@@ -1,5 +1,11 @@
import { DownloadIcon } from '@heroicons/react/outline';
import {
CheckIcon,
InformationCircleIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
MediaRequestStatus,
@@ -9,28 +15,27 @@ import Media from '../../../server/entity/Media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import RequestModal from '../RequestModal';
const messages = defineMessages({
viewrequest: 'View Request',
viewrequest4k: 'View 4K Request',
request: 'Request',
request4k: 'Request 4K',
requestmore: 'Request More',
requestmore4k: 'Request More 4K',
requestmore4k: 'Request More in 4K',
approverequest: 'Approve Request',
approverequest4k: 'Approve 4K Request',
declinerequest: 'Decline Request',
declinerequest4k: 'Decline 4K Request',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
'Approve {requestCount, plural, one {Request} other {{requestCount} Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
'Decline {requestCount, plural, one {Request} other {{requestCount} Requests}}',
approve4krequests:
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
'Approve {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}',
decline4krequests:
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
'Decline {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}',
});
interface ButtonOption {
@@ -59,26 +64,34 @@ const RequestButton: React.FC<RequestButtonProps> = ({
}) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
const [editRequest, setEditRequest] = useState(false);
const activeRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
// All pending
// All pending requests
const activeRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
const activeRequest = useMemo(() => {
return activeRequests && activeRequests.length > 0
? activeRequests.find((request) => request.requestedBy.id === user?.id) ??
activeRequests[0]
: undefined;
}, [activeRequests, user]);
const active4kRequest = useMemo(() => {
return active4kRequests && active4kRequests.length > 0
? active4kRequests.find(
(request) => request.requestedBy.id === user?.id
) ?? active4kRequests[0]
: undefined;
}, [active4kRequests, user]);
const modifyRequest = async (
request: MediaRequest,
type: 'approve' | 'decline'
@@ -110,184 +123,83 @@ const RequestButton: React.FC<RequestButtonProps> = ({
const buttons: ButtonOption[] = [];
if (
(!media || media.status === MediaStatus.UNKNOWN) &&
hasPermission(Permission.REQUEST)
hasPermission(
[
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{ type: 'or' }
)
) {
buttons.push({
id: 'request',
text: intl.formatMessage(messages.request),
text: intl.formatMessage(globalMessages.request),
action: () => {
setEditRequest(false);
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
hasPermission(Permission.REQUEST) &&
mediaType === 'tv' &&
media &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.UNKNOWN &&
!isShowComplete
) {
buttons.push({
id: 'request-more',
text: intl.formatMessage(messages.requestmore),
action: () => {
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
svg: <DownloadIcon />,
});
}
if (
(!media || media.status4k === MediaStatus.UNKNOWN) &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
hasPermission(
[
Permission.REQUEST_4K,
mediaType === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) &&
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
) {
buttons.push({
id: 'request4k',
text: intl.formatMessage(messages.request4k),
text: intl.formatMessage(globalMessages.request4k),
action: () => {
setEditRequest(false);
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
mediaType === 'tv' &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status4k !== MediaStatus.UNKNOWN &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {
buttons.push({
id: 'request-more-4k',
text: intl.formatMessage(messages.requestmore4k),
action: () => {
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
svg: <DownloadIcon />,
});
}
if (
activeRequest &&
mediaType === 'movie' &&
hasPermission(Permission.REQUEST)
(activeRequest.requestedBy.id === user?.id ||
(activeRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-request',
text: intl.formatMessage(messages.viewrequest),
action: () => setShowRequestModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
action: () => {
setEditRequest(true);
setShowRequestModal(true);
},
svg: <InformationCircleIcon />,
});
}
if (
active4kRequest &&
mediaType === 'movie' &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
(active4kRequest.requestedBy.id === user?.id ||
(active4kRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-4k-request',
text: intl.formatMessage(messages.viewrequest4k),
action: () => setShowRequest4kModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
action: () => {
setEditRequest(true);
setShowRequest4kModal(true);
},
svg: <InformationCircleIcon />,
});
}
@@ -303,20 +215,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequest(activeRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
svg: <CheckIcon />,
},
{
id: 'decline-request',
@@ -324,20 +223,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
svg: <XIcon />,
}
);
}
@@ -357,20 +243,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequests(activeRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
svg: <CheckIcon />,
},
{
id: 'decline-request-batch',
@@ -380,20 +253,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
svg: <XIcon />,
}
);
}
@@ -410,20 +270,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequest(active4kRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
svg: <CheckIcon />,
},
{
id: 'decline-4k-request',
@@ -431,20 +278,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
svg: <XIcon />,
}
);
}
@@ -457,54 +291,73 @@ const RequestButton: React.FC<RequestButtonProps> = ({
) {
buttons.push(
{
id: 'approve-request-batch',
id: 'approve-4k-request-batch',
text: intl.formatMessage(messages.approve4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
svg: <CheckIcon />,
},
{
id: 'decline-request-batch',
id: 'decline-4k-request-batch',
text: intl.formatMessage(messages.decline4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
svg: <XIcon />,
}
);
}
if (
mediaType === 'tv' &&
(!activeRequest || activeRequest.requestedBy.id !== user?.id) &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) &&
media &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.UNKNOWN &&
!isShowComplete
) {
buttons.push({
id: 'request-more',
text: intl.formatMessage(messages.requestmore),
action: () => {
setEditRequest(false);
setShowRequestModal(true);
},
svg: <DownloadIcon />,
});
}
if (
mediaType === 'tv' &&
(!active4kRequest || active4kRequest.requestedBy.id !== user?.id) &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status4k !== MediaStatus.UNKNOWN &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {
buttons.push({
id: 'request-more-4k',
text: intl.formatMessage(messages.requestmore4k),
action: () => {
setEditRequest(false);
setShowRequest4kModal(true);
},
svg: <DownloadIcon />,
});
}
const [buttonOne, ...others] = buttons;
if (!buttonOne) {
@@ -517,6 +370,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
tmdbId={tmdbId}
show={showRequestModal}
type={mediaType}
editRequest={editRequest ? activeRequest : undefined}
onComplete={() => {
onUpdate();
setShowRequestModal(false);
@@ -527,6 +381,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
editRequest={editRequest ? active4kRequest : undefined}
is4k
onComplete={() => {
onUpdate();
@@ -537,8 +392,8 @@ const RequestButton: React.FC<RequestButtonProps> = ({
<ButtonWithDropdown
text={
<>
{buttonOne.svg ?? null}
{buttonOne.text}
{buttonOne.svg}
<span>{buttonOne.text}</span>
</>
}
onClick={buttonOne.action}
@@ -551,7 +406,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
key={`request-option-${button.id}`}
>
{button.svg}
{button.text}
<span>{button.text}</span>
</ButtonWithDropdown.Item>
))
: null}

View File

@@ -1,24 +1,38 @@
import React, { useContext, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { TvDetails } from '../../../server/models/Tv';
import type { MovieDetails } from '../../../server/models/Movie';
import useSWR from 'swr';
import { LanguageContext } from '../../context/LanguageContext';
import { MediaRequestStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge';
import { useUser, Permission } from '../../hooks/useUser';
import {
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Button from '../Common/Button';
import { withProperties } from '../../utils/typeHelpers';
import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import { withProperties } from '../../utils/typeHelpers';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import RequestModal from '../RequestModal';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
seasons: 'Seasons',
all: 'All',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
failedretry: 'Something went wrong while retrying the request.',
mediaerror: 'The associated title for this request is no longer available.',
deleterequest: 'Delete Request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -27,7 +41,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
const RequestCardPlaceholder: React.FC = () => {
return (
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
<div className="relative p-4 bg-gray-700 rounded-xl w-72 sm:w-96 animate-pulse">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }} />
</div>
@@ -35,6 +49,45 @@ const RequestCardPlaceholder: React.FC = () => {
);
};
interface RequestCardErrorProps {
mediaId?: number;
}
const RequestCardError: React.FC<RequestCardErrorProps> = ({ mediaId }) => {
const { hasPermission } = useUser();
const intl = useIntl();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
return (
<div className="relative p-4 bg-gray-800 ring-1 ring-red-500 rounded-xl w-72 sm:w-96">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center w-full h-full px-10">
<div className="w-full text-xs text-center text-gray-300 whitespace-normal sm:text-sm">
{intl.formatMessage(messages.mediaerror)}
</div>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
)}
</div>
</div>
</div>
</div>
);
};
interface RequestCardProps {
request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
@@ -45,14 +98,16 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
triggerOnce: true,
});
const intl = useIntl();
const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext);
const { user, hasPermission } = useUser();
const { addToast } = useToasts();
const [isRetrying, setRetrying] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null
inView ? `${url}` : null
);
const {
data: requestData,
@@ -70,6 +125,30 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
};
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
const retryRequest = async () => {
setRetrying(true);
try {
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
if (response) {
revalidate();
}
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setRetrying(false);
}
};
useEffect(() => {
if (title && onTitleData) {
onTitleData(request.id, title);
@@ -85,157 +164,242 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
if (!requestData && !requestError) {
return <RequestCardPlaceholder />;
return <RequestCardError />;
}
if (!title || !requestData) {
return <RequestCardPlaceholder />;
return <RequestCardError mediaId={requestData?.media.id} />;
}
return (
<div
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
style={{
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
}}
>
<div className="flex flex-col flex-1 min-w-0 pr-4">
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
<Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={
request.type === 'movie'
? `/movie/${request.media.tmdbId}`
: `/tv/${request.media.tmdbId}`
}
>
{isMovie(title) ? title.title : title.name}
</Link>
</h2>
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
<>
<RequestModal
show={showEditModal}
tmdbId={request.media.tmdbId}
type={request.type}
is4k={request.is4k}
editRequest={request}
onCancel={() => setShowEditModal(false)}
onComplete={() => {
revalidate();
setShowEditModal(false);
}}
/>
<div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700">
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<span className="text-xs truncate sm:text-sm group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
{requestData.media.status && (
<div className="mt-1 sm:mt-2">
<StatusBadge
status={
requestData.is4k
? requestData.media.status4k
: requestData.media.status
}
is4k={requestData.is4k}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
{request.seasons.length > 0 && (
<div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{!isMovie(title) &&
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(messages.all)}</Badge>
</span>
) : (
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4">
<div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
</div>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex items-end flex-1">
<span className="mr-2">
<Link
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="overflow-hidden text-base font-bold text-white sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
<div className="items-center my-0.5 sm:my-1 text-sm hidden sm:flex">
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
{title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
<div className="flex items-center mt-2 text-sm sm:mt-1">
<span className="hidden mr-2 font-bold sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/>
)}
</div>
<div className="flex items-end flex-1 space-x-2">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="success"
buttonType="primary"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
disabled={isRetrying}
onClick={() => retryRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
<RefreshIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ marginRight: '0', animationDirection: 'reverse' }}
/>
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
</span>
<span>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id &&
(requestData.type === 'tv' ||
hasPermission(Permission.REQUEST_ADVANCED)) && (
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)}
className={`${
hasPermission(Permission.MANAGE_REQUESTS) ? 'sm:hidden' : ''
}`}
>
<PencilIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
onClick={() => deleteRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.cancel)}
</span>
</Button>
</span>
</div>
)}
</div>
<div className="flex-shrink-0 w-20 sm:w-28">
)}
</div>
</div>
<Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={
href={
request.type === 'movie'
? `/movie/${request.media.tmdbId}`
: `/tv/${request.media.tmdbId}`
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<img
src={
title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
/>
<a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
/>
</a>
</Link>
</div>
</div>
</>
);
};

View File

@@ -1,41 +1,88 @@
import React, { useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import {
useIntl,
FormattedDate,
FormattedRelativeTime,
defineMessages,
} from 'react-intl';
import { useUser, Permission } from '../../../hooks/useUser';
import { LanguageContext } from '../../../context/LanguageContext';
import type { MovieDetails } from '../../../../server/models/Movie';
import type { TvDetails } from '../../../../server/models/Tv';
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import React, { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Button from '../../Common/Button';
import axios from 'axios';
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../../server/models/Movie';
import type { TvDetails } from '../../../../server/models/Tv';
import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
import Badge from '../../Common/Badge';
import Button from '../../Common/Button';
import CachedImage from '../../Common/CachedImage';
import ConfirmButton from '../../Common/ConfirmButton';
import RequestModal from '../../RequestModal';
import StatusBadge from '../../StatusBadge';
const messages = defineMessages({
seasons: 'Seasons',
notavailable: 'N/A',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
failedretry: 'Something went wrong while retrying the request.',
requested: 'Requested',
requesteddate: 'Requested',
modified: 'Modified',
modifieduserdate: '{date} by {user}',
mediaerror: 'The associated title for this request is no longer available.',
editrequest: 'Edit Request',
deleterequest: 'Delete Request',
cancelRequest: 'Cancel Request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
interface RequestItemErroProps {
mediaId?: number;
revalidateList: () => void;
}
const RequestItemError: React.FC<RequestItemErroProps> = ({
mediaId,
revalidateList,
}) => {
const intl = useIntl();
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
revalidateList();
};
return (
<div className="flex flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-32">
<span className="text-sm text-center text-gray-300 lg:text-left">
{intl.formatMessage(messages.mediaerror)}
</span>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<div className="mt-4 lg:ml-4 lg:mt-0">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
</div>
);
};
interface RequestItemProps {
request: MediaRequest;
revalidateList: () => void;
@@ -50,15 +97,14 @@ const RequestItem: React.FC<RequestItemProps> = ({
});
const { addToast } = useToasts();
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const [showEditModal, setShowEditModal] = useState(false);
const { locale } = useContext(LanguageContext);
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null
inView ? `${url}` : null
);
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
@@ -101,22 +147,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) {
return (
<tr className="w-full h-24 animate-pulse" ref={ref}>
<td colSpan={6}></td>
</tr>
<div
className="w-full h-64 bg-gray-800 rounded-xl xl:h-32 animate-pulse"
ref={ref}
/>
);
}
if (!title || !requestData) {
return (
<tr className="w-full h-24 animate-pulse">
<td colSpan={6}></td>
</tr>
<RequestItemError
mediaId={requestData?.media.id}
revalidateList={revalidateList}
/>
);
}
return (
<tr className="relative w-full h-24 p-2">
<>
<RequestModal
show={showEditModal}
tmdbId={request.media.tmdbId}
@@ -129,28 +177,26 @@ const RequestItem: React.FC<RequestItemProps> = ({
setShowEditModal(false);
}}
/>
<Table.TD>
<div className="flex items-center">
<Link
href={
request.type === 'movie'
? `/movie/${request.media.tmdbId}`
: `/tv/${request.media.tmdbId}`
}
>
<a className="flex-shrink-0 hidden mr-4 sm:block">
<img
src={
title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
/>
</a>
</Link>
<div className="flex-shrink overflow-hidden">
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row">
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
<CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
<Link
href={
requestData.type === 'movie'
@@ -158,219 +204,285 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center mt-1">
<img
src={requestData.requestedBy.avatar}
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-5 mr-2 rounded-full"
layout="responsive"
width={600}
height={900}
objectFit="cover"
/>
<span className="text-sm hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
{requestData.seasons.length > 0 && (
<div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">
{intl.formatMessage(messages.seasons)}
</span>
{requestData.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
<div className="font-medium pt-0.5 sm:pt-1 text-xs text-white">
{(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{!isMovie(title) && request.seasons.length > 0 && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter(
(season) => season.seasonNumber !== 0
).length === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
))}
{title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="flex overflow-x-scroll hide-scrollbar flex-nowrap">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/>
)}
</div>
<div className="card-field">
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requested)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center truncate group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex text-sm text-gray-300 truncate">
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</>
)}
</div>
{requestData.modifiedBy && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.modified)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="flex items-center truncate group">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</div>
)}
</div>
</div>
</Table.TD>
<Table.TD>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
/>
)}
</Table.TD>
<Table.TD>
<div className="flex flex-col">
<span className="text-sm text-gray-300">
<FormattedDate value={requestData.createdAt} />
</span>
</div>
</Table.TD>
<Table.TD>
<div className="flex flex-col">
{requestData.modifiedBy ? (
<span className="text-sm text-gray-300">
<div className="flex items-center">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="w-5 mr-2 rounded-full"
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 space-y-2 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="w-full"
buttonType="primary"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<RefreshIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ animationDirection: 'reverse' }}
/>
<span className="text-sm">
{requestData.modifiedBy.displayName} (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
)
<span>
{intl.formatMessage(
isRetrying ? globalMessages.retrying : globalMessages.retry
)}
</span>
</Button>
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex flex-row w-full space-x-2">
<span className="w-full">
<Button
className="w-full"
buttonType="success"
onClick={() => modifyRequest('approve')}
>
<CheckIcon />
<span>{intl.formatMessage(globalMessages.approve)}</span>
</Button>
</span>
<span className="w-full">
<Button
className="w-full"
buttonType="danger"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<span>{intl.formatMessage(globalMessages.decline)}</span>
</Button>
</span>
</div>
</span>
) : (
<span className="text-sm text-gray-300">N/A</span>
)}
</div>
</Table.TD>
<Table.TD alignText="right">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="mr-2"
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18px"
height="18px"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.delete)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<span className="mr-2">
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
</span>
<span className="mr-2">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</span>
<span>
)}
{requestData.status === MediaRequestStatus.PENDING &&
(hasPermission(Permission.MANAGE_REQUESTS) ||
(requestData.requestedBy.id === user?.id &&
(requestData.type === 'tv' ||
hasPermission(Permission.REQUEST_ADVANCED)))) && (
<span className="w-full">
<Button
className="w-full"
buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
<PencilIcon />
<span>{intl.formatMessage(messages.editrequest)}</span>
</Button>
</span>
</>
)}
</Table.TD>
</tr>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<XIcon />
<span>{intl.formatMessage(messages.cancelRequest)}</span>
</ConfirmButton>
)}
</div>
</div>
</>
);
};

View File

@@ -1,51 +1,94 @@
import React, { useState } from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import LoadingSpinner from '../Common/LoadingSpinner';
import RequestItem from './RequestItem';
import Header from '../Common/Header';
import Table from '../Common/Table';
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import RequestItem from './RequestItem';
const messages = defineMessages({
requests: 'Requests',
mediaInfo: 'Media Info',
status: 'Status',
requestedAt: 'Requested At',
modifiedBy: 'Last Modified By',
showingresults:
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
resultsperpage: 'Display {pageSize} results per page',
next: 'Next',
previous: 'Previous',
filterAll: 'All',
filterPending: 'Pending',
filterApproved: 'Approved',
filterAvailable: 'Available',
filterProcessing: 'Processing',
noresults: 'No results.',
showallrequests: 'Show All Requests',
sortAdded: 'Request Date',
sortModified: 'Last Modified',
});
type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available';
enum Filter {
ALL = 'all',
PENDING = 'pending',
APPROVED = 'approved',
PROCESSING = 'processing',
AVAILABLE = 'available',
UNAVAILABLE = 'unavailable',
}
type Sort = 'added' | 'modified';
const RequestList: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const [pageIndex, setPageIndex] = useState(0);
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
const { user } = useUser({
id: Number(router.query.userId),
});
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&filter=${currentFilter}&sort=${currentSort}`
}&filter=${currentFilter}&sort=${currentSort}${
router.query.userId ? `&requestedBy=${router.query.userId}` : ''
}`
);
// Restore last set filter values on component mount
useEffect(() => {
const filterString = window.localStorage.getItem('rl-filter-settings');
if (filterString) {
const filterSettings = JSON.parse(filterString);
setCurrentFilter(filterSettings.currentFilter);
setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize);
}
// If filter value is provided in query, use that instead
if (Object.values(Filter).includes(router.query.filter as Filter)) {
setCurrentFilter(router.query.filter as Filter);
}
}, [router.query.filter]);
// Set filter values to local storage any time they are changed
useEffect(() => {
window.localStorage.setItem(
'rl-filter-settings',
JSON.stringify({
currentFilter,
currentSort,
currentPageSize,
})
);
}, [currentFilter, currentSort, currentPageSize]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -59,73 +102,81 @@ const RequestList: React.FC = () => {
return (
<>
<PageTitle title={intl.formatMessage(messages.requests)} />
<div className="flex flex-col justify-between lg:items-end lg:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header>
<PageTitle
title={[
intl.formatMessage(messages.requests),
router.query.userId ? user?.displayName : '',
]}
/>
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
<Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''
)
}
>
{intl.formatMessage(messages.requests)}
</Header>
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
clipRule="evenodd"
/>
</svg>
<FilterIcon className="w-6 h-6" />
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentFilter}
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(messages.filterAll)}
{intl.formatMessage(globalMessages.all)}
</option>
<option value="pending">
{intl.formatMessage(messages.filterPending)}
{intl.formatMessage(globalMessages.pending)}
</option>
<option value="approved">
{intl.formatMessage(messages.filterApproved)}
{intl.formatMessage(globalMessages.approved)}
</option>
<option value="processing">
{intl.formatMessage(messages.filterProcessing)}
{intl.formatMessage(globalMessages.processing)}
</option>
<option value="available">
{intl.formatMessage(messages.filterAvailable)}
{intl.formatMessage(globalMessages.available)}
</option>
<option value="unavailable">
{intl.formatMessage(globalMessages.unavailable)}
</option>
</select>
</div>
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md">
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 3a1 1 0 000 2h11a1 1 0 100-2H3zM3 7a1 1 0 000 2h7a1 1 0 100-2H3zM3 11a1 1 0 100 2h4a1 1 0 100-2H3zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
</svg>
<SortDescendingIcon className="w-6 h-6" />
</span>
<select
id="sort"
name="sort"
onChange={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort);
}}
onBlur={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentSort}
className="rounded-r-only"
@@ -140,114 +191,104 @@ const RequestList: React.FC = () => {
</div>
</div>
</div>
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{data.results.map((request) => {
return (
<RequestItem
request={request}
key={`request-list-${request.id}`}
revalidateList={() => revalidate()}
/>
);
})}
{data.results.map((request) => {
return (
<div className="py-2" key={`request-list-${request.id}`}>
<RequestItem
request={request}
revalidateList={() => revalidate()}
/>
</div>
);
})}
{data.results.length === 0 && (
<tr className="relative h-24 p-2 text-white">
<Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
<span className="text-base">
{intl.formatMessage(messages.noresults)}
</span>
{currentFilter !== 'all' && (
<div className="mt-4">
<Button
buttonSize="sm"
buttonType="primary"
onClick={() => setCurrentFilter('all')}
>
{intl.formatMessage(messages.showallrequests)}
</Button>
</div>
)}
</div>
</Table.TD>
</tr>
)}
<tr className="bg-gray-700">
<Table.TD colSpan={6} noPadding>
<nav
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
aria-label="Pagination"
{data.results.length === 0 && (
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
{currentFilter !== Filter.ALL && (
<div className="mt-4">
<Button
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data.results.length > 0 &&
intl.formatMessage(messages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
{intl.formatMessage(messages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setPageIndex(0);
setCurrentPageSize(Number(e.target.value));
}}
value={currentPageSize}
className="inline short"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)}
{intl.formatMessage(messages.showallrequests)}
</Button>
</div>
)}
</div>
)}
<div className="actions">
<nav
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data.results.length > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="inline short"
>
{intl.formatMessage(messages.previous)}
</Button>
<Button
disabled={!hasNextPage}
onClick={() => setPageIndex((current) => current + 1)}
>
{intl.formatMessage(messages.next)}
</Button>
</div>
</nav>
</Table.TD>
</tr>
</Table.TBody>
</Table>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};

View File

@@ -1,35 +1,50 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Listbox, Transition } from '@headlessui/react';
import { AdjustmentsIcon } from '@heroicons/react/outline';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { isEqual } from 'lodash';
import dynamic from 'next/dynamic';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import useSWR from 'swr';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../../../../server/interfaces/api/serviceInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import { formatBytes } from '../../../utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react';
import { Permission, User, useUser } from '../../../hooks/useUser';
import type { UserResultsResponse } from '../../../../server/interfaces/api/userInterfaces';
import { Permission, User, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages';
import { formatBytes } from '../../../utils/numberHelpers';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
type OptionType = {
value: string;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
advancedoptions: 'Advanced Options',
advancedoptions: 'Advanced',
destinationserver: 'Destination Server',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
animenote: '* This series is an anime.',
default: '(Default)',
loadingprofiles: 'Loading profiles…',
loadingfolders: 'Loading folders…',
default: '{name} (Default)',
folder: '{path} ({space})',
requestas: 'Request As',
languageprofile: 'Language Profile',
loadinglanguages: 'Loading languages…',
tags: 'Tags',
selecttags: 'Select tags',
notagoptions: 'No tags.',
});
export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
tags?: number[];
language?: number;
user?: User;
};
@@ -78,6 +93,10 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
defaultOverrides?.language ?? -1
);
const [selectedTags, setSelectedTags] = useState<number[]>(
defaultOverrides?.tags ?? []
);
const {
data: serverData,
isValidating,
@@ -100,7 +119,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
const { data: userData } = useSWR<UserResultsResponse>(
hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS])
? '/api/v1/user?take=1000'
? '/api/v1/user?take=1000&sort=displayname'
: null
);
@@ -151,6 +170,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
? serverData.server.activeAnimeLanguageProfileId
: serverData.server.activeLanguageProfileId)
);
const defaultTags = isAnime
? serverData.server.activeAnimeTags
: serverData.server.activeTags;
if (
defaultProfile &&
@@ -175,46 +197,43 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
) {
setSelectedLanguage(defaultLanguage.id);
}
if (
defaultTags &&
!isEqual(defaultTags, selectedTags) &&
(!defaultOverrides || defaultOverrides.tags === null)
) {
setSelectedTags(defaultTags);
}
}
}, [serverData]);
useEffect(() => {
if (
defaultOverrides &&
defaultOverrides.server !== null &&
defaultOverrides.server !== undefined
) {
if (defaultOverrides && defaultOverrides.server != null) {
setSelectedServer(defaultOverrides.server);
}
if (
defaultOverrides &&
defaultOverrides.profile !== null &&
defaultOverrides.profile !== undefined
) {
if (defaultOverrides && defaultOverrides.profile != null) {
setSelectedProfile(defaultOverrides.profile);
}
if (
defaultOverrides &&
defaultOverrides.folder !== null &&
defaultOverrides.folder !== undefined
) {
if (defaultOverrides && defaultOverrides.folder != null) {
setSelectedFolder(defaultOverrides.folder);
}
if (
defaultOverrides &&
defaultOverrides.language !== null &&
defaultOverrides.language !== undefined
) {
if (defaultOverrides && defaultOverrides.language != null) {
setSelectedLanguage(defaultOverrides.language);
}
if (defaultOverrides && defaultOverrides.tags != null) {
setSelectedTags(defaultOverrides.tags);
}
}, [
defaultOverrides?.server,
defaultOverrides?.folder,
defaultOverrides?.profile,
defaultOverrides?.language,
defaultOverrides?.tags,
]);
useEffect(() => {
@@ -225,6 +244,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
language: selectedLanguage ?? undefined,
tags: selectedTags,
});
}
}, [
@@ -233,6 +253,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
selectedProfile,
selectedUser,
selectedLanguage,
selectedTags,
]);
if (!data && !error) {
@@ -250,23 +271,15 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
return (
<>
<div className="flex items-center mb-2 font-bold tracking-wider">
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.707 7.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L13 8.586V5h3a2 2 0 012 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2V7a2 2 0 012-2h3v3.586L9.707 7.293zM11 3a1 1 0 112 0v2h-2V3z" />
<path d="M4 9a2 2 0 00-2 2v5a2 2 0 002 2h8a2 2 0 002-2H4V9z" />
</svg>
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
{intl.formatMessage(messages.advancedoptions)}
</div>
<div className="p-4 bg-gray-600 rounded-md shadow">
{!!data && selectedServer !== null && (
<>
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
<label htmlFor="server" className="text-label">
<div className="flex flex-col md:flex-row">
{data.filter((server) => server.is4k === is4k).length > 1 && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
@@ -275,20 +288,30 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
>
{data.map((server) => (
<option key={`server-list-${server.id}`} value={server.id}>
{server.name}
{server.isDefault && server.is4k === is4k
? ` ${intl.formatMessage(messages.default)}`
: ''}
</option>
))}
{data
.filter((server) => server.is4k === is4k)
.map((server) => (
<option
key={`server-list-${server.id}`}
value={server.id}
>
{server.isDefault
? intl.formatMessage(messages.default, {
name: server.name,
})
: server.name}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
<label htmlFor="profile" className="text-label">
)}
{(isValidating ||
!serverData ||
serverData.profiles.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
@@ -297,11 +320,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadingprofiles)}
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
@@ -311,24 +335,27 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`profile-list${profile.id}`}
value={profile.id}
>
{profile.name}
{isAnime &&
serverData.server.activeAnimeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: profile.name,
})
: !isAnime &&
serverData.server.activeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: profile.name,
})
: profile.name}
</option>
))}
</select>
</div>
<div
className={`flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0 ${
type === 'tv' ? 'md:pr-4' : ''
}`}
>
<label htmlFor="folder" className="text-label">
)}
{(isValidating ||
!serverData ||
serverData.rootFolders.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
@@ -337,11 +364,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadingfolders)}
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
@@ -351,21 +379,37 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`folder-list${folder.id}`}
value={folder.path}
>
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: !isAnime &&
serverData.server.activeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
})}
</option>
))}
</select>
</div>
{type === 'tv' && (
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
<label htmlFor="language" className="text-label">
)}
{type === 'tv' &&
(isValidating ||
!serverData ||
(serverData.languageProfiles ?? []).length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="language">
{intl.formatMessage(messages.languageprofile)}
</label>
<select
@@ -378,11 +422,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
onBlur={(e) =>
setSelectedLanguage(parseInt(e.target.value))
}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadinglanguages)}
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
@@ -392,146 +437,169 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`folder-list${language.id}`}
value={language.id}
>
{language.name}
{isAnime &&
serverData.server.activeAnimeLanguageProfileId ===
language.id
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: language.name,
})
: !isAnime &&
serverData.server.activeLanguageProfileId ===
language.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: language.name,
})
: language.name}
</option>
))}
</select>
</div>
)}
</div>
</>
</div>
)}
{selectedServer !== null &&
(isValidating || !serverData || !!serverData?.tags?.length) && (
<div className="mb-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
isDisabled={isValidating || !serverData}
placeholder={
isValidating || !serverData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container react-select-container-dark"
classNamePrefix="react-select"
value={selectedTags.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="mt-0 sm:mt-2">
<Listbox
as="div"
value={selectedUser}
onChange={(value) => setSelectedUser(value)}
className="space-y-1"
>
{({ open }) => (
<>
<Listbox.Label className="text-label">
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="block ml-3">
{selectedUser.displayName}
</span>
<Listbox
as="div"
value={selectedUser}
onChange={(value) => setSelectedUser(value)}
className="space-y-1"
>
{({ open }) => (
<>
<Listbox.Label>
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="block ml-3">
{selectedUser.displayName}
</span>
{selectedUser.displayName.toLowerCase() !==
selectedUser.email && (
<span className="ml-1 text-gray-400 truncate">
({selectedUser.email})
</span>
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-500"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
</span>
</Listbox.Button>
</span>
<Transition
show={open}
enter="transition ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
<Transition
show={open}
enter="transition ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
{userData?.results.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div
{userData?.results.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
{user.displayName}
</span>
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
{user.displayName}
</span>
{user.displayName.toLowerCase() !==
user.email && (
<span className="ml-1 text-gray-400 truncate">
({user.email})
</span>
</span>
{selected && (
<span
className={`${
active
? 'text-white'
: 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</div>
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{isAnime && (
<div className="mt-4 italic">

View File

@@ -1,45 +1,36 @@
import React, { useCallback, useState, useEffect } from 'react';
import Modal from '../Common/Modal';
import { useUser } from '../../hooks/useUser';
import { Permission } from '../../../server/lib/permissions';
import { defineMessages, useIntl } from 'react-intl';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import useSWR from 'swr';
import { MovieDetails } from '../../../server/models/Movie';
import { useToasts } from 'react-toast-notifications';
import { DownloadIcon } from '@heroicons/react/outline';
import axios from 'axios';
import {
MediaStatus,
MediaRequestStatus,
} from '../../../server/constants/media';
import DownloadIcon from '../../assets/download.svg';
import Alert from '../Common/Alert';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions';
import { MovieDetails } from '../../../server/models/Movie';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
const messages = defineMessages({
requestadmin:
'Your request will be immediately approved. Do you wish to continue?',
cancelrequest:
'This will remove your request. Are you sure you want to continue?',
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestCancel: 'Request for <strong>{title}</strong> canceled',
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> requested successfully!',
requestCancel: 'Request for <strong>{title}</strong> canceled.',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
close: 'Close',
edit: 'Edit Request',
cancel: 'Cancel Request',
cancelling: 'Canceling…',
pendingrequest: 'Pending request for {title}',
pending4krequest: 'Pending request for {title} in 4K',
requesting: 'Requesting…',
request: 'Request',
request4k: 'Request 4K',
requestfrom: 'There is a pending request from {username}.',
request4kfrom: 'There is a pending 4K request from {username}.',
pendingrequest: 'Pending Request for {title}',
pending4krequest: 'Pending 4K Request for {title}',
requestfrom: "{username}'s request is pending approval.",
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request edited.',
autoapproval: 'Automatic Approval',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -70,6 +61,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);
useEffect(() => {
if (onUpdating) {
@@ -88,6 +82,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@@ -132,18 +127,14 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
} finally {
setIsUpdating(false);
}
}, [data, onComplete, addToast, requestOverrides]);
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.is4k === !!is4k
);
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]);
const cancelRequest = async () => {
setIsUpdating(true);
try {
const response = await axios.delete<MediaRequest>(
`/api/v1/request/${activeRequest?.id}`
`/api/v1/request/${editRequest?.id}`
);
if (response.status === 204) {
@@ -177,12 +168,23 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
addToast(<span>{intl.formatMessage(messages.requestedited)}</span>, {
appearance: 'success',
autoDismiss: true,
});
addToast(
<span>
{intl.formatMessage(messages.requestedited, {
title: data?.title,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
})}
</span>,
{
appearance: 'success',
autoDismiss: true,
}
);
if (onComplete) {
onComplete(MediaStatus.PENDING);
@@ -197,12 +199,15 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}
};
const isOwner = activeRequest
? activeRequest.requestedBy.id === user?.id ||
hasPermission(Permission.MANAGE_REQUESTS)
: false;
if (editRequest) {
const isOwner = editRequest.requestedBy.id === user?.id;
const showEditButton = hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
{
type: 'or',
}
);
if (activeRequest?.status === MediaRequestStatus.PENDING) {
return (
<Modal
loading={!data && !error}
@@ -210,47 +215,48 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
title={intl.formatMessage(
is4k ? messages.pending4krequest : messages.pendingrequest,
{
title: data?.title,
}
{ title: data?.title }
)}
onOk={() => updateRequest()}
onOk={() => (showEditButton ? updateRequest() : cancelRequest())}
okDisabled={isUpdating}
okText={intl.formatMessage(globalMessages.edit)}
okButtonType="primary"
onSecondary={isOwner ? () => cancelRequest() : undefined}
secondaryDisabled={isUpdating}
secondaryText={
isUpdating
? intl.formatMessage(messages.cancelling)
okText={
showEditButton
? intl.formatMessage(messages.edit)
: intl.formatMessage(messages.cancel)
}
okButtonType={showEditButton ? 'primary' : 'danger'}
onSecondary={
isOwner && showEditButton ? () => cancelRequest() : undefined
}
secondaryDisabled={isUpdating}
secondaryText={
isOwner && showEditButton
? intl.formatMessage(messages.cancel)
: undefined
}
secondaryButtonType="danger"
cancelText={intl.formatMessage(messages.close)}
iconSvg={<DownloadIcon className="w-6 h-6" />}
cancelText={intl.formatMessage(globalMessages.close)}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{intl.formatMessage(
is4k ? messages.request4kfrom : messages.requestfrom,
{
username: activeRequest.requestedBy.displayName,
}
)}
{isOwner
? intl.formatMessage(messages.pendingapproval)
: intl.formatMessage(messages.requestfrom, {
username: editRequest.requestedBy.displayName,
})}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4">
<AdvancedRequester
type="movie"
is4k={is4k}
requestUser={editRequest?.requestedBy}
defaultOverrides={
editRequest
? {
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
}
: undefined
}
requestUser={editRequest.requestedBy}
defaultOverrides={{
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
@@ -261,39 +267,55 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
);
}
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
],
{ type: 'or' }
);
return (
<Modal
loading={!data && !error}
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating}
okDisabled={isUpdating || quota?.movie.restricted}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title }
)}
okText={
isUpdating
? intl.formatMessage(messages.requesting)
: intl.formatMessage(is4k ? messages.request4k : messages.request)
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(
is4k ? globalMessages.request4k : globalMessages.request
)
}
okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{(hasPermission(Permission.MANAGE_REQUESTS) ||
hasPermission(
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE
) ||
hasPermission(
is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE
)) && (
<p className="mt-6">
<Alert title={intl.formatMessage(messages.autoapproval)} type="info">
{intl.formatMessage(messages.requestadmin)}
</Alert>
</p>
{hasAutoApprove && !quota?.movie.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.movie.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="movie"
quota={quota?.movie}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (

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