Merge branch 'develop' into release/release-20210104

This commit is contained in:
sct
2021-01-04 11:54:45 +00:00
84 changed files with 2179 additions and 455 deletions

View File

@@ -196,7 +196,8 @@
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4", "avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
"profile": "https://github.com/danshilm", "profile": "https://github.com/danshilm",
"contributions": [ "contributions": [
"code" "code",
"doc"
] ]
}, },
{ {
@@ -235,6 +236,15 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc"
]
} }
], ],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>", "badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

5
.gitbook.yaml Normal file
View File

@@ -0,0 +1,5 @@
root: ./docs
structure:
readme: README.md
summary: SUMMARY.md

View File

@@ -38,7 +38,7 @@ All help is welcome and greatly appreciated. If you would like to contribute to
``` ```
yarn yarn
yarn install yarn dev
``` ```
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. - Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.

View File

@@ -1,5 +1,8 @@
FROM node:12.18-alpine AS BUILD_IMAGE FROM node:12.18-alpine AS BUILD_IMAGE
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
@@ -25,6 +28,8 @@ COPY --from=BUILD_IMAGE /app/dist ./dist
COPY --from=BUILD_IMAGE /app/.next ./.next COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
CMD yarn start CMD yarn start
EXPOSE 5055 EXPOSE 5055

View File

@@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a> <a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"> <img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-25-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-26-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
</p> </p>
@@ -36,16 +36,21 @@
- User profiles. - User profiles.
- User settings page (to give users the ability to modify their Overseerr experience to their liking). - User settings page (to give users the ability to modify their Overseerr experience to their liking).
- Version update notifications in-app.
- 4K requests (Includes multi-radarr/sonarr management for media) - 4K requests (Includes multi-radarr/sonarr management for media)
## Planned Features ## Planned Features
- More notification types (Slack/Telegram/etc.). - More notification types.
- Issues system. This will allow users to report issues with content on your media server. - Issues system. This will allow users to report issues with content on your media server.
- Local user system (for those who don't use Plex). - Local user system (for those who don't use Plex).
- Compatibility APIs (to work with existing tools in your system). - Compatibility APIs (to work with existing tools in your system).
## Getting Started
Check out our documenation for steps on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
## Running Overseerr ## Running Overseerr
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per: Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
@@ -70,6 +75,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
## Support ## Support
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7). - You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions). - You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues). - Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
@@ -123,13 +129,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td> <td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td> <td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td> <td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a></td> <td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
</tr> </tr>
<tr> <tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> <td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td> <td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td> <td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td> <td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
</tr> </tr>
</table> </table>

22
docs/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Introduction
Welcome to the Overseerr Documentation.
## Features
- **Full Plex integration**. Login and manage user access with Plex.
- **Syncs to your Plex library** to show what titles you already have.
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
- **Simple request management UI**. Don't dig through the app to approve recent requests.
- **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system.
- Localization into other languages.
## Motivation
The primary motivation for starting this project was to have an incredibly performant and easy to use application. There is a heavy focus on the user experience for both the server owner and the users. We feel requesting should be **effortless for the user**. Find the media you want, click request, and branch off efficiently into other titles that interest you, all in one seamless flow. For the server owner, Overseerr takes all the hassle out of approving your users' requests.
## We need your help!
Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub.

17
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,17 @@
# Table of contents
* [Introduction](README.md)
## Getting Started
* [Installation](getting-started/installation.md)
## Support
* [Frequently Asked Questions](support/faq.md)
* [Asking for Support](support/asking-for-support.md)
## Extending Overseerr
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)

View File

@@ -0,0 +1,132 @@
# Reverse Proxy Examples
{% hint style="warning" %}
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
{% endhint %}
## LE/SWAG
### Subdomain
Place in the `proxy-confs` folder as `overseerr.subdomain.conf`
Example Configuration:
```text
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name overseerr.*;
include /config/nginx/ssl.conf;
client_max_body_size 0;
location / {
include /config/nginx/proxy.conf;
resolver 127.0.0.11 valid=30s;
set $upstream_app overseerr;
set $upstream_port 5055;
set $upstream_proto http;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
}
}
```
## Traefik \(v2\)
Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
### Subdomain
Example Configuration:
```text
labels:
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.overseerr-rtr.entrypoints=https"
- "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
- "traefik.http.routers.overseerr-rtr.tls=true"
## HTTP Services
- "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
```
## LE/NGINX
### Subdomain
Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`.
Create a symlink to `/etc/nginx/sites-enabled`:
```text
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
Test the configuration:
```text
sudo nginx -t
```
Reload your configuration for NGINX:
```text
sudo systemctl reload nginx
```
Example Configuration:
```text
server {
listen 80;
server_name overseerr.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name overseerr.example.com;
ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
proxy_set_header Referer $http_referer;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-Host $host:$remote_port;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Ssl on;
real_ip_header CF-Connecting-IP;
# Control the behavior of the Referer header (Referrer-Policy)
add_header Referrer-Policy "no-referrer";
# HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Reduce XSS risks (Content-Security-Policy)
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# Prevent some categories of XSS attacks (X-XSS-Protection)
add_header X-XSS-Protection "1; mode=block" always;
# Provide clickjacking protection (X-Frame-Options)
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent Sniff Mimetype (X-Content-Type-Options)
add_header X-Content-Type-Options "nosniff" always;
access_log /var/log/nginx/overseerr.example.com-access.log;
error_log /var/log/nginx/overseerr.example.com-error.log;
location / {
proxy_pass http://127.0.0.1:5055;
}
}
```

View File

@@ -0,0 +1,196 @@
# Installation
{% hint style="danger" %}
Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`** instead!
{% endhint %}
{% hint style="info" %}
After running Overseerr for the first time, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps.
{% endhint %}
## Docker
{% tabs %}
{% tab title="Basic" %}
```bash
docker run -d \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="UID/GID" %}
```text
docker run -d \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="Manual Update" %}
```text
# Stop the Overseerr container
docker stop overseerr
# Remove the Overseerr container
docker rm overseerr
# Pull the latest update
docker pull sctx/overseerr
# Run the Overseerr container with the same parameters as before
docker run -d ...
```
{% endtab %}
{% endtabs %}
{% hint style="info" %}
Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
{% endhint %}
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**\(Appdata\) as needed.
5. Click apply and access "Overseerr" at your `<ServerIP:HostPort>` in a web browser.
## Windows
Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
{% hint style="danger" %}
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed! Details below.**
{% endhint %}
```bash
docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/here:/app/config" --restart unless-stopped sctx/overseerr
```
{% hint style="info" %}
Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
{% endhint %}
## Linux \(Unsupported\)
{% tabs %}
{% tab title="Ubuntu 16.04+/Debian" %}
{% hint style="danger" %}
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
{% endhint %}
```bash
# Install nodejs
sudo apt-get install -y curl git gnupg2
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
# Install Overseerr
cd ~ && git clone https://github.com/sct/overseerr.git
cd overseerr
yarn install
yarn build
yarn start
```
**Updating**
In order to update, you will need to re-build overseer.
```bash
cd ~/.overseerr
git pull
yarn install
yarn build
yarn start
```
{% endtab %}
{% tab title="Ubuntu ARM" %}
{% hint style="danger" %}
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
{% endhint %}
```bash
# Install nodejs
sudo apt-get install -y curl git gnupg2 build-essential
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
# Install Overseerr
cd ~ && git clone https://github.com/sct/overseerr.git
cd overseerr
npm config set python "$(which python3)"
yarn install
yarn build
yarn start
```
**Updating**
In order to update, you will need to re-build overseer.
```bash
cd ~/.overseerr
git pull
yarn install
yarn build
yarn start
```
{% endtab %}
{% tab title="ArchLinux \(3rd Party\)" %}
Built from tag \(master\): [https://aur.archlinux.org/packages/overseerr/](https://aur.archlinux.org/packages/overseerr/)
Built from latest \(develop\): [aur.archlinux.org/packages/overseerr-git](https://aur.archlinux.org/packages/overseerr-git/)
**To install these just use your favorite AUR package manager:**
```bash
yay -S overseer
```
{% endtab %}
{% tab title="Gentoo \(3rd Party\)" %}
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
To enable using eselect repository, run:
```bash
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
```
Once complete, you can just run:
```bash
emerge www-apps/overseerr
```
{% endtab %}
{% endtabs %}
## Swizzin \(Third party\)
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
To install, run the following:
```bash
box install overseerr
```
To upgrade, run the following:
```bash
box upgrade overseerr
```

View File

@@ -0,0 +1,34 @@
# Asking for Support
## Before asking for support, make sure you try these things first
* Make sure you have **updated** to the latest version.
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
* **Analyzing** your logs, you just might find the solution yourself!
* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
## What should I include when asking for support?
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
* What command did you enter?
* What did you click on?
* What settings did you change?
* Provide a step-by-step list of what you tried.
* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
* Did something happen?
* Did something not happen?
* Are there any error messages showing?
* Screenshots can help us see what you are seeing
* The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
## How can I share my logs?
First you will need to gather your logs from the install directory.
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.

95
docs/support/faq.md Normal file
View File

@@ -0,0 +1,95 @@
# Frequently Asked Questions
{% hint style="info" %}
If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
{% endhint %}
## General
### I receive 409 or 400 errors when requesting a movie or tv show!
**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible.
### How do I keep Overseerr up-to-date?
**A:** Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
### How can I access Overseerr outside my home network?
**A:** The easy and least secure method is to forward an external port \(`5055`\) on your router to the internal port used by Overseerr \(default is TCP `5055`\). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You will then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
The more advanced and most preferred method \(and more secure if you use SSL\) is to set up a web server with NGINX/Apache, and use a reverse proxy to access Overseerr. You can lookup many guides on the internet to find out how to do this. There are several reverse proxy config examples located [here](../extending-overseerr/reverse-proxy-examples.md).
The most secure method, but also the most inconvenient, is to set up a VPN tunnel to your home server, then you can access Overseerr as if it is on a local network via `http://LOCAL-IP-ADDRESS:5055`.
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
**A:** You sure can! We are using Weblate for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
**A:** You can find the changelog in the **Settings -&gt; About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases).
### Can I make 4K requests?
**A:** 4K requests are not supported just yet but they will be supported in the future!
### Some media is missing from Overseerr that I know is in Plex!
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
**Troubleshooting Steps:**
Check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
1. Verify that you are using one of the agents mentioned above.
2. Refresh the metadata for just that item.
3. Run a full scan in Overseerr to see if that item is now matched properly.
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
5. Run a full scan on Overseerr after refreshing all unmatched items.
Perform these steps to verify the media item has a guid Overseerr can match.
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
2. Verify that the media item has the same format of one of the examples below.
**Examples:**
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
### Where can I find the logs?
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
## User Management
### Why can't I see all my Plex users?
**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -&gt; General Settings** page beforehand.
### Can I create local users in Overseerr?
**A:** Not at this time. But it is a planned feature!
### Is is possible to set user roles in Overseerr?
**A:** Unfortunately, this is not possible yet. It is planned!
## Requests
### I approved a requested movie and radarr didn't search for it!
**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr.
### Help! My request still shows "requested" even though it's in Plex!?!
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
## Notifications
### I am getting "Username and Password not accepted" when sending email notifications to gmail!
**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833).

View File

@@ -1,4 +1,7 @@
module.exports = { module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
},
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({
test: /\.svg$/, test: /\.svg$/,

View File

@@ -855,6 +855,22 @@ components:
properties: properties:
webhookUrl: webhookUrl:
type: string type: string
TelegramSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
botAPI:
type: string
chatId:
type: string
NotificationEmailSettings: NotificationEmailSettings:
type: object type: object
properties: properties:
@@ -870,6 +886,9 @@ components:
emailFrom: emailFrom:
type: string type: string
example: no-reply@example.com example: no-reply@example.com
senderName:
type: string
example: Overseerr
smtpHost: smtpHost:
type: string type: string
example: 127.0.0.1 example: 127.0.0.1
@@ -1083,6 +1102,26 @@ components:
name: X-Api-Key name: X-Api-Key
paths: paths:
/status:
get:
summary: Return Overseerr version
description: Returns the current Overseerr version in JSON format
security: []
tags:
- public
responses:
'200':
description: Returned version
content:
application/json:
schema:
type: object
properties:
version:
type: string
example: 1.0.0
commitTag:
type: string
/settings/main: /settings/main:
get: get:
summary: Returns main settings summary: Returns main settings
@@ -1635,6 +1674,52 @@ paths:
responses: responses:
'204': '204':
description: Test notification attempted description: Test notification attempted
/settings/notifications/telegram:
get:
summary: Return current telegram notification settings
description: Returns current telegram notification settings in JSON format
tags:
- settings
responses:
'200':
description: Returned telegram settings
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
post:
summary: Update telegram notification settings
description: Update current telegram notification settings with provided values
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
/settings/notifications/telegram/test:
post:
summary: Test the provided telegram settings
description: Sends a test notification to the telegram agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/slack: /settings/notifications/slack:
get: get:
summary: Return current slack notification settings summary: Return current slack notification settings

View File

@@ -171,7 +171,7 @@
[ [
"@semantic-release/exec", "@semantic-release/exec",
{ {
"prepareCmd": "docker build -t sctx/overseerr ." "prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
} }
], ],
"semantic-release-docker", "semantic-release-docker",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 407 KiB

BIN
public/images/rotate5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
public/images/rotate6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View File

@@ -40,12 +40,15 @@ class Media {
} }
} }
public static async getMedia(id: number): Promise<Media | undefined> { public static async getMedia(
id: number,
mediaType: MediaType
): Promise<Media | undefined> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { tmdbId: id }, where: { tmdbId: id, mediaType },
relations: ['requests'], relations: ['requests'],
}); });
@@ -62,7 +65,7 @@ class Media {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public mediaType: MediaType; public mediaType: MediaType;
@Column({ unique: true }) @Column()
@Index() @Index()
public tmdbId: number; public tmdbId: number;
@@ -70,7 +73,7 @@ class Media {
@Index() @Index()
public tvdbId?: number; public tvdbId?: number;
@Column({ unique: true, nullable: true }) @Column({ nullable: true })
@Index() @Index()
public imdbId?: string; public imdbId?: string;

View File

@@ -17,6 +17,7 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications'; import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord'; import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email'; import EmailAgent from './lib/notifications/agents/email';
import TelegramAgent from './lib/notifications/agents/telegram';
import { getAppVersion } from './utils/appVersion'; import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack'; import SlackAgent from './lib/notifications/agents/slack';
@@ -47,6 +48,7 @@ app
new DiscordAgent(), new DiscordAgent(),
new EmailAgent(), new EmailAgent(),
new SlackAgent(), new SlackAgent(),
new TelegramAgent(),
]); ]);
// Start Jobs // Start Jobs

View File

@@ -44,11 +44,11 @@ class JobPlexSync {
this.isRecentOnly = isRecentOnly ?? false; this.isRecentOnly = isRecentOnly ?? false;
} }
private async getExisting(tmdbId: number) { private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({ const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId }, where: { tmdbId: tmdbId, mediaType },
}); });
return existing; return existing;
@@ -78,7 +78,10 @@ class JobPlexSync {
} }
}); });
const existing = await this.getExisting(newMedia.tmdbId); const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (existing && existing.status === MediaStatus.AVAILABLE) { if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`); this.log(`Title exists and is already available ${metadata.title}`);
@@ -115,7 +118,7 @@ class JobPlexSync {
throw new Error('Unable to find TMDB ID'); throw new Error('Unable to find TMDB ID');
} }
const existing = await this.getExisting(tmdbMovieId); const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
if (existing && existing.status === MediaStatus.AVAILABLE) { if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`); this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) { } else if (existing && existing.status !== MediaStatus.AVAILABLE) {
@@ -184,9 +187,7 @@ class JobPlexSync {
if (tvShow && metadata) { if (tvShow && metadata) {
// Lets get the available seasons from plex // Lets get the available seasons from plex
const seasons = tvShow.seasons; const seasons = tvShow.seasons;
const media = await mediaRepository.findOne({ const media = await this.getExisting(tvShow.id, MediaType.TV);
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
});
const newSeasons: Season[] = []; const newSeasons: Season[] = [];

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { Notification } from '..'; import { hasNotificationType, Notification } from '..';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentDiscord } from '../../settings'; import { getSettings, NotificationAgentDiscord } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -196,10 +196,12 @@ class DiscordAgent
}; };
} }
// TODO: Add checking for type here once we add notification type filters for agents public shouldSend(type: Notification): boolean {
// eslint-disable-next-line @typescript-eslint/no-unused-vars if (
public shouldSend(_type: Notification): boolean { this.getSettings().enabled &&
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true; return true;
} }

View File

@@ -1,5 +1,5 @@
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { Notification } from '..'; import { hasNotificationType, Notification } from '..';
import path from 'path'; import path from 'path';
import { getSettings, NotificationAgentEmail } from '../../settings'; import { getSettings, NotificationAgentEmail } from '../../settings';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
@@ -22,12 +22,13 @@ class EmailAgent
return settings.notifications.agents.email; return settings.notifications.agents.email;
} }
// TODO: Add checking for type here once we add notification type filters for agents public shouldSend(type: Notification): boolean {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = this.getSettings(); const settings = this.getSettings();
if (settings.enabled) { if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types)
) {
return true; return true;
} }
@@ -60,7 +61,10 @@ class EmailAgent
const settings = this.getSettings(); const settings = this.getSettings();
return new Email({ return new Email({
message: { message: {
from: settings.options.emailFrom, from: {
name: settings.options.senderName,
address: settings.options.emailFrom,
},
}, },
send: true, send: true,
transport: this.getSmtpTransport(), transport: this.getSmtpTransport(),

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { Notification } from '..'; import { hasNotificationType, Notification } from '..';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings'; import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -187,10 +187,12 @@ class SlackAgent
}; };
} }
// TODO: Add checking for type here once we add notification type filters for agents public shouldSend(type: Notification): boolean {
// eslint-disable-next-line @typescript-eslint/no-unused-vars if (
public shouldSend(_type: Notification): boolean { this.getSettings().enabled &&
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true; return true;
} }

View File

@@ -0,0 +1,127 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramPayload {
text: string;
parse_mode: string;
chat_id: string;
}
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
private baseUrl = 'https://api.telegram.org/';
protected getSettings(): NotificationAgentTelegram {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private escapeText(text: string | undefined): string {
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
}
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.username);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New Request\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nPending Approval\n`;
break;
case Notification.MEDIA_APPROVED:
message += `\*Request Approved\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nProcessing Request\n`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*Now available\\!\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nAvailable\n`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
}
/* eslint-enable */
return message;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/sendMessage`;
await axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
} as TelegramPayload);
return true;
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default TelegramAgent;

View File

@@ -9,6 +9,27 @@ export enum Notification {
TEST_NOTIFICATION = 32, TEST_NOTIFICATION = 32,
} }
export const hasNotificationType = (
types: Notification | Notification[],
value: number
): 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;
}
return !!(value & total);
};
class NotificationManager { class NotificationManager {
private activeAgents: NotificationAgent[] = []; private activeAgents: NotificationAgent[] = [];

View File

@@ -81,6 +81,14 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
authUser?: string; authUser?: string;
authPass?: string; authPass?: string;
allowSelfSigned: boolean; allowSelfSigned: boolean;
senderName: string;
};
}
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
botAPI: string;
chatId: string;
}; };
} }
@@ -88,6 +96,7 @@ interface NotificationAgents {
email: NotificationAgentEmail; email: NotificationAgentEmail;
discord: NotificationAgentDiscord; discord: NotificationAgentDiscord;
slack: NotificationAgentSlack; slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram;
} }
interface NotificationSettings { interface NotificationSettings {
@@ -140,6 +149,7 @@ class Settings {
smtpPort: 587, smtpPort: 587,
secure: false, secure: false,
allowSelfSigned: false, allowSelfSigned: false,
senderName: 'Overseerr',
}, },
}, },
discord: { discord: {
@@ -156,6 +166,14 @@ class Settings {
webhookUrl: '', webhookUrl: '',
}, },
}, },
telegram: {
enabled: false,
types: 0,
options: {
botAPI: '',
chatId: '',
},
},
}, },
}, },
}; };

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface {
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
}

View File

@@ -1,4 +1,5 @@
import { TmdbCollection } from '../api/themoviedb'; import { TmdbCollection } from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media'; import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search'; import { mapMovieResult, MovieResult } from './Search';
@@ -23,7 +24,9 @@ export const mapCollection = (
parts: collection.parts.map((part) => parts: collection.parts.map((part) =>
mapMovieResult( mapMovieResult(
part, part,
media?.find((req) => req.tmdbId === part.id) media?.find(
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE
)
) )
), ),
}); });

View File

@@ -3,6 +3,7 @@ import type {
TmdbPersonResult, TmdbPersonResult,
TmdbTvResult, TmdbTvResult,
} from '../api/themoviedb'; } from '../api/themoviedb';
import { MediaType as MainMediaType } from '../constants/media';
import Media from '../entity/Media'; import Media from '../entity/Media';
export type MediaType = 'tv' | 'movie' | 'person'; export type MediaType = 'tv' | 'movie' | 'person';
@@ -122,12 +123,18 @@ export const mapSearchResults = (
case 'movie': case 'movie':
return mapMovieResult( return mapMovieResult(
result, result,
media?.find((req) => req.tmdbId === result.id) media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
)
); );
case 'tv': case 'tv':
return mapTvResult( return mapTvResult(
result, result,
media?.find((req) => req.tmdbId === result.id) media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
); );
default: default:
return mapPersonResult(result); return mapPersonResult(result);

View File

@@ -26,7 +26,9 @@ discoverRoutes.get('/movies', async (req, res) => {
results: data.results.map((result) => results: data.results.map((result) =>
mapMovieResult( mapMovieResult(
result, result,
media.find((req) => req.tmdbId === result.id) media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
) )
), ),
}); });

View File

@@ -13,10 +13,19 @@ import tvRoutes from './tv';
import mediaRoutes from './media'; import mediaRoutes from './media';
import personRoutes from './person'; import personRoutes from './person';
import collectionRoutes from './collection'; import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
const router = Router(); const router = Router();
router.use(checkUser); router.use(checkUser);
router.get('/status', (req, res) => {
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
});
});
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => { router.get('/settings/public', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

View File

@@ -5,6 +5,7 @@ import { mapMovieResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes'; import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger'; import logger from '../logger';
import { MediaType } from '../constants/media';
const movieRoutes = Router(); const movieRoutes = Router();
@@ -17,7 +18,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
language: req.query.language as string, language: req.query.language as string,
}); });
const media = await Media.getMedia(tmdbMovie.id); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
return res.status(200).json(mapMovieDetails(tmdbMovie, media)); return res.status(200).json(mapMovieDetails(tmdbMovie, media));
} catch (e) { } catch (e) {
@@ -49,7 +50,9 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
results: results.results.map((result) => results: results.results.map((result) =>
mapMovieResult( mapMovieResult(
result, result,
media.find((req) => req.tmdbId === result.id) media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
) )
), ),
}); });
@@ -75,7 +78,9 @@ movieRoutes.get('/:id/similar', async (req, res) => {
results: results.results.map((result) => results: results.results.map((result) =>
mapMovieResult( mapMovieResult(
result, result,
media.find((req) => req.tmdbId === result.id) media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
) )
), ),
}); });

View File

@@ -45,13 +45,19 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
cast: combinedCredits.cast.map((result) => cast: combinedCredits.cast.map((result) =>
mapCastCredits( mapCastCredits(
result, result,
castMedia.find((med) => med.tmdbId === result.id) castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
) )
), ),
crew: combinedCredits.crew.map((result) => crew: combinedCredits.crew.map((result) =>
mapCrewCredits( mapCrewCredits(
result, result,
crewMedia.find((med) => med.tmdbId === result.id) crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
) )
), ),
id: combinedCredits.id, id: combinedCredits.id,

View File

@@ -102,7 +102,7 @@ requestRoutes.post(
: await tmdb.getTvShow({ tvId: req.body.mediaId }); : await tmdb.getTvShow({ tvId: req.body.mediaId });
let media = await mediaRepository.findOne({ let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId }, where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: ['requests'], relations: ['requests'],
}); });
@@ -164,7 +164,7 @@ requestRoutes.post(
if (finalSeasons.length === 0) { if (finalSeasons.length === 0) {
return next({ return next({
status: 500, status: 202,
message: 'No seasons available to request', message: 'No seasons available to request',
}); });
} }

View File

@@ -25,6 +25,7 @@ import { Notification } from '../lib/notifications';
import DiscordAgent from '../lib/notifications/agents/discord'; import DiscordAgent from '../lib/notifications/agents/discord';
import EmailAgent from '../lib/notifications/agents/email'; import EmailAgent from '../lib/notifications/agents/email';
import SlackAgent from '../lib/notifications/agents/slack'; import SlackAgent from '../lib/notifications/agents/slack';
import TelegramAgent from '../lib/notifications/agents/telegram';
const settingsRoutes = Router(); const settingsRoutes = Router();
@@ -503,6 +504,40 @@ settingsRoutes.post('/notifications/slack/test', (req, res, next) => {
return res.status(204).send(); return res.status(204).send();
}); });
settingsRoutes.get('/notifications/telegram', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.telegram);
});
settingsRoutes.post('/notifications/telegram', (req, res) => {
const settings = getSettings();
settings.notifications.agents.telegram = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.telegram);
});
settingsRoutes.post('/notifications/telegram/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const telegramAgent = new TelegramAgent(req.body);
telegramAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/notifications/email', (_req, res) => { settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

View File

@@ -5,6 +5,7 @@ import { mapTvResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes'; import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger'; import logger from '../logger';
import { MediaType } from '../constants/media';
const tvRoutes = Router(); const tvRoutes = Router();
@@ -16,7 +17,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
language: req.query.language as string, language: req.query.language as string,
}); });
const media = await Media.getMedia(tv.id); const media = await Media.getMedia(tv.id, MediaType.TV);
return res.status(200).json(mapTvDetails(tv, media)); return res.status(200).json(mapTvDetails(tv, media));
} catch (e) { } catch (e) {
@@ -60,7 +61,9 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
results: results.results.map((result) => results: results.results.map((result) =>
mapTvResult( mapTvResult(
result, result,
media.find((req) => req.tmdbId === result.id) media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
)
) )
), ),
}); });
@@ -86,7 +89,9 @@ tvRoutes.get('/:id/similar', async (req, res) => {
results: results.results.map((result) => results: results.results.map((result) =>
mapTvResult( mapTvResult(
result, result,
media.find((req) => req.tmdbId === result.id) media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
)
) )
), ),
}); });

View File

@@ -1,3 +1,20 @@
import { existsSync } from 'fs';
import path from 'path';
import logger from '../logger';
const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json');
let commitTag = 'local';
if (existsSync(COMMIT_TAG_PATH)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
commitTag = require(COMMIT_TAG_PATH).commitTag;
logger.info(`Commit Tag: ${commitTag}`);
}
export const getCommitTag = (): string => {
return commitTag;
};
export const getAppVersion = (): string => { export const getAppVersion = (): string => {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json'); const { version } = require('../../package.json');
@@ -5,7 +22,7 @@ export const getAppVersion = (): string => {
let finalVersion = version; let finalVersion = version;
if (version === '0.1.0') { if (version === '0.1.0') {
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`; finalVersion = `develop-${getCommitTag()}`;
} }
return finalVersion; return finalVersion;

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path fill-rule="evenodd" clip-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" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

1
src/assets/xcircle.svg Normal file
View File

@@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><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"></path></svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@@ -102,7 +102,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return ( return (
<div <div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 " className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{ style={{
height: 493, 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})`, 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})`,

View File

@@ -7,7 +7,7 @@ interface ListItemProps {
const ListItem: React.FC<ListItemProps> = ({ title, children }) => { const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
return ( return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4"> <div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-200">{title}</dt> <dt className="text-sm font-medium text-gray-200">{title}</dt>
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2"> <dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span> <span className="flex-grow">{children}</span>

View File

@@ -98,7 +98,7 @@ const Modal: React.FC<ModalProps> = ({
show={!loading} show={!loading}
> >
<div <div
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full" className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full max-h-full"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-headline" aria-labelledby="modal-headline"
@@ -166,7 +166,7 @@ const Modal: React.FC<ModalProps> = ({
<Button <Button
buttonType={cancelButtonType} buttonType={cancelButtonType}
onClick={onCancel} onClick={onCancel}
className="ml-3 sm:ml-0 sm:px-4" className="ml-3 sm:ml-0"
> >
{cancelText {cancelText
? cancelText ? cancelText

View File

@@ -61,7 +61,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
> >
<div className="w-screen max-w-md" ref={slideoverRef}> <div className="w-screen max-w-md" ref={slideoverRef}>
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl"> <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 sm:px-6"> <header className="px-4 py-6 space-y-1 bg-indigo-600">
<div className="flex items-center justify-between space-x-3"> <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-medium leading-7 text-white">
{title} {title}
@@ -97,7 +97,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div> </div>
)} )}
</header> </header>
<div className="relative flex-1 px-4 py-6 text-white sm:px-6"> <div className="relative flex-1 px-4 py-6 text-white">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -71,8 +71,8 @@ const TD: React.FC<TDProps> = ({
const Table: React.FC = ({ children }) => { const Table: React.FC = ({ children }) => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4"> <div className="my-2 overflow-x-auto -mx-6 md:mx-0 lg:mx-0">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> <div className="py-2 align-middle inline-block min-w-full">
<div className="shadow overflow-hidden sm:rounded-lg"> <div className="shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full">{children}</table> <table className="min-w-full">{children}</table>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import useSearchInput from '../../../hooks/useSearchInput'; import useSearchInput from '../../../hooks/useSearchInput';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import ClearButton from '../../../assets/xcircle.svg';
const messages = defineMessages({ const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV', searchPlaceholder: 'Search Movies & TV',
@@ -8,16 +9,16 @@ const messages = defineMessages({
const SearchInput: React.FC = () => { const SearchInput: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { searchValue, setSearchValue, setIsOpen } = useSearchInput(); const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
return ( return (
<div className="flex-1 flex"> <div className="flex flex-1">
<div className="w-full flex md:ml-0"> <div className="flex w-full md:ml-0">
<label htmlFor="search_field" className="sr-only"> <label htmlFor="search_field" className="sr-only">
Search Search
</label> </label>
<div className="relative w-full text-white focus-within:text-gray-200"> <div className="relative w-full text-white focus-within:text-gray-200">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
@@ -27,14 +28,27 @@ const SearchInput: React.FC = () => {
</div> </div>
<input <input
id="search_field" id="search_field"
className="block w-full h-full pl-8 pr-1 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base" style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
className="block w-full h-full py-2 pl-8 text-white placeholder-gray-300 bg-gray-600 border-transparent rounded-md focus:border-transparent focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)} placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search" type="search"
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)} onBlur={() => {
if (searchValue === '') {
setIsOpen(false);
}
}}
/> />
{searchValue.length > 0 && (
<button
className="absolute inset-y-0 right-0 p-1 m-auto text-gray-400 transition border-none outline-none h-7 w-7 focus:outline-none focus:border-none hover:text-white"
onClick={() => clear()}
>
<ClearButton />
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -175,11 +175,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
> >
<div className="flex-shrink-0 flex items-center px-4"> <div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50"> <span className="text-xl text-gray-50">
<Link href="/"> <a href="/">
<a>
<img src="/logo.png" alt="Overseerr Logo" /> <img src="/logo.png" alt="Overseerr Logo" />
</a> </a>
</Link>
</span> </span>
</div> </div>
<nav className="mt-5 px-2 space-y-1"> <nav className="mt-5 px-2 space-y-1">
@@ -239,11 +237,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> <div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4"> <div className="flex items-center flex-shrink-0 px-4">
<span className="text-2xl text-gray-50"> <span className="text-2xl text-gray-50">
<Link href="/"> <a href="/">
<a>
<img src="/logo.png" alt="Overseerr Logo" /> <img src="/logo.png" alt="Overseerr Logo" />
</a> </a>
</Link>
</span> </span>
</div> </div>
<nav className="mt-5 flex-1 px-2 bg-gray-800 space-y-1"> <nav className="mt-5 flex-1 px-2 bg-gray-800 space-y-1">

View File

@@ -18,18 +18,18 @@ const Layout: React.FC = ({ children }) => {
const router = useRouter(); const router = useRouter();
return ( return (
<div className="min-h-full h-full flex bg-gray-900"> <div className="flex h-full min-w-0 min-h-full bg-gray-900">
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} /> <Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="flex flex-col w-0 flex-1 md:ml-64 relative mb-16"> <div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 md:ml-64">
<div className="z-10 flex-shrink-0 flex h-16 bg-gray-600 shadow fixed right-0 left-0 md:left-64"> <div className="fixed left-0 right-0 z-10 flex flex-shrink-0 h-16 bg-gray-600 shadow md:left-64">
<button <button
className="px-4 border-r border-gray-800 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden" className="px-4 text-gray-200 border-r border-gray-800 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
aria-label="Open sidebar" aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<svg <svg
className="h-6 w-6" className="w-6 h-6"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -42,7 +42,7 @@ const Layout: React.FC = ({ children }) => {
/> />
</svg> </svg>
</button> </button>
<div className="flex-1 px-4 flex justify-between"> <div className="flex justify-between flex-1 px-4">
<SearchInput /> <SearchInput />
<div className="flex items-center md:ml-6"> <div className="flex items-center md:ml-6">
<LanguagePicker /> <LanguagePicker />
@@ -51,18 +51,15 @@ const Layout: React.FC = ({ children }) => {
</div> </div>
</div> </div>
<main <main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
className="relative z-0 top-16 focus:outline-none right-0" <div className="pt-2 pb-6">
tabIndex={0} <div className="px-4 mx-auto max-w-8xl">
>
<div className="pt-2 pb-6 md:py-6">
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
{router.pathname === '/' && hasPermission(Permission.ADMIN) && ( {router.pathname === '/' && hasPermission(Permission.ADMIN) && (
<div className="rounded-md bg-indigo-700 p-4 mt-2"> <div className="p-4 mt-2 bg-indigo-700 rounded-md">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg <svg
className="h-5 w-5 text-white" className="w-5 h-5 text-white"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
@@ -74,14 +71,14 @@ const Layout: React.FC = ({ children }) => {
/> />
</svg> </svg>
</div> </div>
<div className="ml-3 flex-1 md:flex md:justify-between"> <div className="flex-1 ml-3 md:flex md:justify-between">
<p className="text-sm leading-5 text-white"> <p className="text-sm leading-5 text-white">
<FormattedMessage {...messages.alphawarning} /> <FormattedMessage {...messages.alphawarning} />
</p> </p>
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6"> <p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
<a <a
href="http://github.com/sct/overseerr" href="http://github.com/sct/overseerr"
className="whitespace-nowrap font-medium text-indigo-100 hover:text-white transition ease-in-out duration-150" className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View File

@@ -51,31 +51,33 @@ const Login: React.FC = () => {
}, [user, router]); }, [user, router]);
return ( return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative"> <div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
<ImageFader <ImageFader
backgroundImages={[ backgroundImages={[
'/images/rotate1.jpg', '/images/rotate1.jpg',
'/images/rotate2.jpg', '/images/rotate2.jpg',
'/images/rotate3.jpg', '/images/rotate3.jpg',
'/images/rotate4.jpg', '/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]} ]}
/> />
<div className="absolute top-4 right-4 z-50"> <div className="absolute z-50 top-4 right-4">
<LanguagePicker /> <LanguagePicker />
</div> </div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-md relative z-40"> <div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img <img
src="/logo.png" src="/logo.png"
className="mx-auto max-h-32 w-auto" className="w-auto mx-auto max-h-32"
alt="Overseerr Logo" alt="Overseerr Logo"
/> />
<h2 className="mt-2 text-center text-3xl leading-9 font-extrabold text-gray-100"> <h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<FormattedMessage {...messages.signinplex} /> <FormattedMessage {...messages.signinplex} />
</h2> </h2>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md relative z-50"> <div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div <div
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10" className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
style={{ backdropFilter: 'blur(5px)' }} style={{ backdropFilter: 'blur(5px)' }}
> >
<Transition <Transition
@@ -87,11 +89,11 @@ const Login: React.FC = () => {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="rounded-md bg-red-600 p-4 mb-4"> <div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg <svg
className="h-5 w-5 text-red-300" className="w-5 h-5 text-red-300"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"

View File

@@ -146,7 +146,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return ( return (
<div <div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 " className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{ style={{
height: 493, 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})`, 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})`,

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { NotificationItem, hasNotificationType } from '..';
interface NotificationTypeProps {
option: NotificationItem;
currentTypes: number;
parent?: NotificationItem;
onUpdate: (newTypes: number) => void;
}
const NotificationType: React.FC<NotificationTypeProps> = ({
option,
currentTypes,
onUpdate,
parent,
}) => {
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
!!parent?.value && hasNotificationType(parent.value, currentTypes)
? 'opacity-50'
: ''
}`}
>
<div className="flex items-center h-5">
<input
id={option.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
!!parent?.value && hasNotificationType(parent.value, currentTypes)
}
onClick={() => {
onUpdate(
hasNotificationType(option.value, currentTypes)
? currentTypes - option.value
: currentTypes + option.value
);
}}
defaultChecked={
hasNotificationType(option.value, currentTypes) ||
(!!parent?.value &&
hasNotificationType(parent.value, currentTypes))
}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label htmlFor={option.id} className="font-medium">
{option.name}
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`notification-type-child-${child.id}`} className="pl-6 mt-4">
<NotificationType
option={child}
currentTypes={currentTypes}
onUpdate={(newTypes) => onUpdate(newTypes)}
parent={option}
/>
</div>
))}
</>
);
};
export default NotificationType;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import NotificationType from './NotificationType';
const messages = defineMessages({
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
mediaapproved: 'Media Approved',
mediaapprovedDescription: 'Sends a notification when media is approved.',
mediaavailable: 'Media Available',
mediaavailableDescription:
'Sends a notification when media becomes available.',
mediafailed: 'Media Failed',
mediafailedDescription:
'Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
});
export const hasNotificationType = (
types: Notification | Notification[],
value: number
): boolean => {
let total = 0;
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
return !!(value & total);
};
export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
}
export interface NotificationItem {
id: string;
name: string;
description: string;
value: Notification;
children?: NotificationItem[];
}
interface NotificationTypeSelectorProps {
currentTypes: number;
onUpdate: (newTypes: number) => void;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
currentTypes,
onUpdate,
}) => {
const intl = useIntl();
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-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,
},
];
return (
<>
{types.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
currentTypes={currentTypes}
onUpdate={onUpdate}
/>
))}
</>
);
};
export default NotificationTypeSelector;

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react'; import React, { useContext, useMemo, useState } from 'react';
import TruncateMarkup from 'react-truncate-markup'; import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr'; import useSWR from 'swr';
import type { PersonDetail } from '../../../server/models/Person'; import type { PersonDetail } from '../../../server/models/Person';
@@ -11,6 +11,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext'; import { LanguageContext } from '../../context/LanguageContext';
import ImageFader from '../Common/ImageFader'; import ImageFader from '../Common/ImageFader';
import Ellipsis from '../../assets/ellipsis.svg'; import Ellipsis from '../../assets/ellipsis.svg';
import { groupBy } from 'lodash';
const messages = defineMessages({ const messages = defineMessages({
appearsin: 'Appears in', appearsin: 'Appears in',
@@ -35,6 +36,42 @@ const PersonDetails: React.FC = () => {
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}` `/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
); );
const sortedCast = useMemo(() => {
const grouped = groupBy(combinedCredits?.cast ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
character: objs.map((pos) => pos.character).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
const sortedCrew = useMemo(() => {
const grouped = groupBy(combinedCredits?.crew ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
job: objs.map((pos) => pos.job).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@@ -43,24 +80,6 @@ const PersonDetails: React.FC = () => {
return <Error statusCode={404} />; return <Error statusCode={404} />;
} }
const sortedCast = combinedCredits?.cast.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
const isLoading = !combinedCredits && !errorCombinedCredits; const isLoading = !combinedCredits && !errorCombinedCredits;
const cast = (sortedCast ?? []).length > 0 && ( const cast = (sortedCast ?? []).length > 0 && (

View File

@@ -43,7 +43,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
return ( return (
<div className="block"> <div className="block">
<div className="px-4 py-4 sm:px-6"> <div className="px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0"> <div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
<div className="flex flex-nowrap mb-1 white"> <div className="flex flex-nowrap mb-1 white">

View File

@@ -100,7 +100,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) { if (!title && !error) {
return ( return (
<tr className="w-full bg-gray-800 animate-pulse h-24" ref={ref}> <tr className="w-full h-24 bg-gray-800 animate-pulse" ref={ref}>
<td colSpan={6}></td> <td colSpan={6}></td>
</tr> </tr>
); );
@@ -108,18 +108,16 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title || !requestData) { if (!title || !requestData) {
return ( return (
<tr className="w-full bg-gray-800 animate-pulse h-24"> <tr className="w-full h-24 bg-gray-800 animate-pulse">
<td colSpan={6}></td> <td colSpan={6}></td>
</tr> </tr>
); );
} }
return ( return (
<tr className="w-full bg-gray-800 h-24 p-2 relative text-white"> <tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<Table.TD <Table.TD>
noPadding <div className="flex items-center">
className="w-20 px-4 relative hidden sm:table-cell align-middle"
>
<Link <Link
href={ href={
request.type === 'movie' request.type === 'movie'
@@ -127,16 +125,15 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${request.media.tmdbId}` : `/tv/${request.media.tmdbId}`
} }
> >
<a> <a className="flex-shrink-0 hidden mr-4 sm:block">
<img <img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`} src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt="" alt=""
className="rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md" className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
/> />
</a> </a>
</Link> </Link>
</Table.TD> <div className="flex-shrink overflow-hidden">
<Table.TD>
<Link <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
@@ -144,7 +141,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${requestData.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<a className="text-white text-xl mr-2 hover:underline"> <a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
{isMovie(title) ? title.title : title.name} {isMovie(title) ? title.title : title.name}
</a> </a>
</Link> </Link>
@@ -154,8 +151,10 @@ const RequestItem: React.FC<RequestItemProps> = ({
})} })}
</div> </div>
{requestData.seasons.length > 0 && ( {requestData.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center"> <div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span> <span className="mr-2">
{intl.formatMessage(messages.seasons)}
</span>
{requestData.seasons.map((season) => ( {requestData.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2"> <span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge> <Badge>{season.seasonNumber}</Badge>
@@ -163,6 +162,8 @@ const RequestItem: React.FC<RequestItemProps> = ({
))} ))}
</div> </div>
)} )}
</div>
</div>
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
{requestData.media.status === MediaStatus.UNKNOWN ? ( {requestData.media.status === MediaStatus.UNKNOWN ? (

View File

@@ -18,14 +18,28 @@ const messages = defineMessages({
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results', 'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
next: 'Next', next: 'Next',
previous: 'Previous', previous: 'Previous',
filterAll: 'All',
filterPending: 'Pending',
filterApproved: 'Approved',
noresults: 'No Results.',
showallrequests: 'Show All Requests',
sortAdded: 'Request Date',
sortModified: 'Last Modified',
}); });
type Filter = 'all' | 'approved' | 'pending';
type Sort = 'added' | 'modified';
const RequestList: React.FC = () => { const RequestList: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
const [currentSort, setCurrentSort] = useState<Sort>('added');
const { data, error, revalidate } = useSWR<RequestResultsResponse>( const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=10&skip=${pageIndex * 10}` `/api/v1/request?take=10&skip=${
pageIndex * 10
}&filter=${currentFilter}&sort=${currentSort}`
); );
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -40,10 +54,86 @@ const RequestList: React.FC = () => {
return ( return (
<> <>
<div className="flex flex-col justify-between md:items-end md:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header> <Header>{intl.formatMessage(messages.requests)}</Header>
<div className="flex flex-col md:flex-row">
<div className="flex mb-2 md:mb-0 md:mr-2">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<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>
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter);
}}
onBlur={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter);
}}
value={currentFilter}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="all">
{intl.formatMessage(messages.filterAll)}
</option>
<option value="pending">
{intl.formatMessage(messages.filterPending)}
</option>
<option value="approved">
{intl.formatMessage(messages.filterApproved)}
</option>
</select>
</div>
<div className="flex">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<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>
</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);
}}
value={currentSort}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="added">
{intl.formatMessage(messages.sortAdded)}
</option>
<option value="modified">
{intl.formatMessage(messages.sortModified)}
</option>
</select>
</div>
</div>
</div>
<Table> <Table>
<thead> <thead>
<Table.TH className="hidden sm:table-cell"></Table.TH>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH> <Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH> <Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH> <Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
@@ -60,10 +150,31 @@ const RequestList: React.FC = () => {
/> />
); );
})} })}
{data.results.length === 0 && (
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center p-4">
<span>{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> <tr>
<Table.TD colSpan={6} noPadding> <Table.TD colSpan={6} noPadding>
<nav <nav
className="bg-gray-700 px-4 py-3 flex items-center justify-between text-white sm:px-6" className="flex items-center justify-between px-4 py-3 text-white bg-gray-700"
aria-label="Pagination" aria-label="Pagination"
> >
<div className="hidden sm:block"> <div className="hidden sm:block">
@@ -73,7 +184,7 @@ const RequestList: React.FC = () => {
to: to:
data.results.length < 10 data.results.length < 10
? pageIndex * 10 + data.results.length ? pageIndex * 10 + data.results.length
: pageIndex + 1 * 10, : (pageIndex + 1) * 10,
total: data.pageInfo.results, total: data.pageInfo.results,
strong: function strong(msg) { strong: function strong(msg) {
return <span className="font-medium">{msg}</span>; return <span className="font-medium">{msg}</span>;
@@ -81,7 +192,7 @@ const RequestList: React.FC = () => {
})} })}
</p> </p>
</div> </div>
<div className="flex-1 flex justify-start sm:justify-end"> <div className="flex justify-start flex-1 sm:justify-end">
<span className="mr-2"> <span className="mr-2">
<Button <Button
disabled={!hasPrevPage} disabled={!hasPrevPage}

View File

@@ -168,7 +168,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
okButtonType={'primary'} okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />} iconSvg={<DownloadIcon className="w-6 h-6" />}
> >
{text} <p className="text-center md:text-left">{text}</p>
</Modal> </Modal>
); );
}; };

View File

@@ -224,7 +224,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="-mx-4 overflow-auto sm:mx-0 max-h-96"> <div className="-mx-4 overflow-auto sm:mx-0 max-h-96">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> <div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg"> <div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full"> <table className="min-w-full">
<thead> <thead>
@@ -256,13 +256,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span> ></span>
</span> </span>
</th> </th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500"> <th className="px-1 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.season)} {intl.formatMessage(messages.season)}
</th> </th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500"> <th className="px-5 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.numberofepisodes)} {intl.formatMessage(messages.numberofepisodes)}
</th> </th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500"> <th className="px-2 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.status)} {intl.formatMessage(messages.status)}
</th> </th>
</tr> </tr>
@@ -320,17 +320,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span> ></span>
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap"> <td className="px-1 md:px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
{season.seasonNumber === 0 {season.seasonNumber === 0
? intl.formatMessage(messages.extras) ? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, { : intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber, number: season.seasonNumber,
})} })}
</td> </td>
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap"> <td className="px-5 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{season.episodeCount} {season.episodeCount}
</td> </td>
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap"> <td className="pr-2 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{!seasonRequest && !mediaSeason && ( {!seasonRequest && !mediaSeason && (
<Badge> <Badge>
{intl.formatMessage(messages.notrequested)} {intl.formatMessage(messages.notrequested)}

View File

@@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios'; import axios from 'axios';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({ const messages = defineMessages({
save: 'Save Changes', save: 'Save Changes',
@@ -19,6 +20,7 @@ const messages = defineMessages({
discordsettingsfailed: 'Discord notification settings failed to save.', discordsettingsfailed: 'Discord notification settings failed to save.',
testsent: 'Test notification sent!', testsent: 'Test notification sent!',
test: 'Test', test: 'Test',
notificationtypes: 'Notification Types',
}); });
const NotificationsDiscord: React.FC = () => { const NotificationsDiscord: React.FC = () => {
@@ -69,7 +71,7 @@ const NotificationsDiscord: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, values, isValid }) => { {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => { const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/discord/test', { await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true, enabled: true,
@@ -87,10 +89,10 @@ const NotificationsDiscord: React.FC = () => {
return ( return (
<Form> <Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="isDefault" htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.agentenabled)} {intl.formatMessage(messages.agentenabled)}
</label> </label>
@@ -99,19 +101,19 @@ const NotificationsDiscord: React.FC = () => {
type="checkbox" type="checkbox"
id="enabled" id="enabled"
name="enabled" name="enabled"
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out" className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.webhookUrl)} {intl.formatMessage(messages.webhookUrl)}
</label> </label>
<div className="mt-1 sm:mt-0 sm:col-span-2"> <div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm"> <div className="flex max-w-lg rounded-md shadow-sm">
<Field <Field
id="webhookUrl" id="webhookUrl"
name="webhookUrl" name="webhookUrl"
@@ -119,17 +121,41 @@ const NotificationsDiscord: React.FC = () => {
placeholder={intl.formatMessage( placeholder={intl.formatMessage(
messages.webhookUrlPlaceholder messages.webhookUrlPlaceholder
)} )}
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/> />
</div> </div>
{errors.webhookUrl && touched.webhookUrl && ( {errors.webhookUrl && touched.webhookUrl && (
<div className="text-red-500 mt-2">{errors.webhookUrl}</div> <div className="mt-2 text-red-500">{errors.webhookUrl}</div>
)} )}
</div> </div>
</div> </div>
<div className="mt-8 border-t border-gray-700 pt-5"> <div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">
<Button <Button
buttonType="warning" buttonType="warning"
disabled={isSubmitting || !isValid} disabled={isSubmitting || !isValid}
@@ -142,7 +168,7 @@ const NotificationsDiscord: React.FC = () => {
{intl.formatMessage(messages.test)} {intl.formatMessage(messages.test)}
</Button> </Button>
</span> </span>
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
type="submit" type="submit"

View File

@@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios'; import axios from 'axios';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({ const messages = defineMessages({
save: 'Save Changes', save: 'Save Changes',
@@ -28,6 +29,8 @@ const messages = defineMessages({
allowselfsigned: 'Allow Self-Signed Certificates', allowselfsigned: 'Allow Self-Signed Certificates',
ssldisabletip: ssldisabletip:
'SSL should be disabled on standard TLS connections (Port 587)', 'SSL should be disabled on standard TLS connections (Port 587)',
senderName: 'Sender Name',
notificationtypes: 'Notification Types',
}); });
const NotificationsEmail: React.FC = () => { const NotificationsEmail: React.FC = () => {
@@ -37,7 +40,7 @@ const NotificationsEmail: React.FC = () => {
'/api/v1/settings/notifications/email' '/api/v1/settings/notifications/email'
); );
const NotificationsDiscordSchema = Yup.object().shape({ const NotificationsEmailSchema = Yup.object().shape({
emailFrom: Yup.string().required( emailFrom: Yup.string().required(
intl.formatMessage(messages.validationFromRequired) intl.formatMessage(messages.validationFromRequired)
), ),
@@ -65,8 +68,9 @@ const NotificationsEmail: React.FC = () => {
authUser: data.options.authUser, authUser: data.options.authUser,
authPass: data.options.authPass, authPass: data.options.authPass,
allowSelfSigned: data.options.allowSelfSigned, allowSelfSigned: data.options.allowSelfSigned,
senderName: data.options.senderName,
}} }}
validationSchema={NotificationsDiscordSchema} validationSchema={NotificationsEmailSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/notifications/email', { await axios.post('/api/v1/settings/notifications/email', {
@@ -80,6 +84,7 @@ const NotificationsEmail: React.FC = () => {
authUser: values.authUser, authUser: values.authUser,
authPass: values.authPass, authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned, allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
}, },
}); });
addToast(intl.formatMessage(messages.emailsettingssaved), { addToast(intl.formatMessage(messages.emailsettingssaved), {
@@ -96,7 +101,7 @@ const NotificationsEmail: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, values, isValid }) => { {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => { const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/email/test', { await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true, enabled: true,
@@ -108,6 +113,7 @@ const NotificationsEmail: React.FC = () => {
secure: values.secure, secure: values.secure,
authUser: values.authUser, authUser: values.authUser,
authPass: values.authPass, authPass: values.authPass,
senderName: values.senderName,
}, },
}); });
@@ -119,10 +125,10 @@ const NotificationsEmail: React.FC = () => {
return ( return (
<Form> <Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="enabled" htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.agentenabled)} {intl.formatMessage(messages.agentenabled)}
</label> </label>
@@ -135,10 +141,10 @@ const NotificationsEmail: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="emailFrom" htmlFor="emailFrom"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.emailsender)} {intl.formatMessage(messages.emailsender)}
</label> </label>
@@ -157,10 +163,29 @@ const NotificationsEmail: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="senderName"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.senderName)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="senderName"
name="senderName"
placeholder="Overseerr"
type="text"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="smtpHost" htmlFor="smtpHost"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.smtpHost)} {intl.formatMessage(messages.smtpHost)}
</label> </label>
@@ -179,10 +204,10 @@ const NotificationsEmail: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="smtpPort" htmlFor="smtpPort"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.smtpPort)} {intl.formatMessage(messages.smtpPort)}
</label> </label>
@@ -201,10 +226,10 @@ const NotificationsEmail: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="secure" htmlFor="secure"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span>{intl.formatMessage(messages.enableSsl)}</span> <span>{intl.formatMessage(messages.enableSsl)}</span>
@@ -222,10 +247,10 @@ const NotificationsEmail: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="allowSelfSigned" htmlFor="allowSelfSigned"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.allowselfsigned)} {intl.formatMessage(messages.allowselfsigned)}
</label> </label>
@@ -238,10 +263,10 @@ const NotificationsEmail: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="authUser" htmlFor="authUser"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.authUser)} {intl.formatMessage(messages.authUser)}
</label> </label>
@@ -256,10 +281,10 @@ const NotificationsEmail: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="authPass" htmlFor="authPass"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.authPass)} {intl.formatMessage(messages.authPass)}
</label> </label>
@@ -269,11 +294,36 @@ const NotificationsEmail: React.FC = () => {
id="authPass" id="authPass"
name="authPass" name="authPass"
type="password" type="password"
autoComplete="off"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700"> <div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">

View File

@@ -8,6 +8,7 @@ import axios from 'axios';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import Alert from '../../../Common/Alert'; import Alert from '../../../Common/Alert';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({ const messages = defineMessages({
save: 'Save Changes', save: 'Save Changes',
@@ -23,6 +24,7 @@ const messages = defineMessages({
settingupslack: 'Setting up Slack Notifications', settingupslack: 'Setting up Slack Notifications',
settingupslackDescription: settingupslackDescription:
'To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.', 'To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.',
notificationtypes: 'Notification Types',
}); });
const NotificationsSlack: React.FC = () => { const NotificationsSlack: React.FC = () => {
@@ -44,7 +46,6 @@ const NotificationsSlack: React.FC = () => {
return ( return (
<> <>
<p className="mb-">
<Alert title={intl.formatMessage(messages.settingupslack)} type="info"> <Alert title={intl.formatMessage(messages.settingupslack)} type="info">
{intl.formatMessage(messages.settingupslackDescription, { {intl.formatMessage(messages.settingupslackDescription, {
WebhookLink: function WebhookLink(msg) { WebhookLink: function WebhookLink(msg) {
@@ -61,7 +62,6 @@ const NotificationsSlack: React.FC = () => {
}, },
})} })}
</Alert> </Alert>
</p>
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
@@ -92,7 +92,14 @@ const NotificationsSlack: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, values, isValid }) => { {({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
}) => {
const testSettings = async () => { const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/slack/test', { await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true, enabled: true,
@@ -110,10 +117,10 @@ const NotificationsSlack: React.FC = () => {
return ( return (
<Form> <Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="isDefault" htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.agentenabled)} {intl.formatMessage(messages.agentenabled)}
</label> </label>
@@ -126,10 +133,10 @@ const NotificationsSlack: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.webhookUrl)} {intl.formatMessage(messages.webhookUrl)}
</label> </label>
@@ -150,6 +157,30 @@ const NotificationsSlack: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700"> <div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">

View File

@@ -0,0 +1,255 @@
import React from 'react';
import { Field, Form, Formik } from 'formik';
import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
import Alert from '../../Common/Alert';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
save: 'Save Changes',
saving: 'Saving...',
agentenabled: 'Agent Enabled',
botAPI: 'Bot API',
chatId: 'Chat Id',
validationBotAPIRequired: 'You must provide a Bot API key.',
validationChatIdRequired: 'You must provide a Chat id.',
telegramsettingssaved: 'Telegram notification settings saved!',
telegramsettingsfailed: 'Telegram notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
settinguptelegram: 'Setting up Telegram Notifications',
settinguptelegramDescription:
'To setup Telegram you need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key.\
Additionally, you need the chat id for the chat you want the bot to send notifications to.\
You can do this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat or group chat.',
notificationtypes: 'Notification Types',
});
const NotificationsTelegram: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/telegram'
);
const NotificationsTelegramSchema = Yup.object().shape({
botAPI: Yup.string().required(
intl.formatMessage(messages.validationBotAPIRequired)
),
chatId: Yup.string().required(
intl.formatMessage(messages.validationChatIdRequired)
),
});
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enabled: data?.enabled,
types: data?.types,
botAPI: data?.options.botAPI,
chatId: data?.options.chatId,
}}
validationSchema={NotificationsTelegramSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/telegram', {
enabled: values.enabled,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
},
});
addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.telegramsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/telegram/test', {
enabled: true,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<>
<Alert
title={intl.formatMessage(messages.settinguptelegram)}
type="info"
>
{intl.formatMessage(messages.settinguptelegramDescription, {
CreateBotLink: function CreateBotLink(msg) {
return (
<a
href="https://core.telegram.org/bots#6-botfather"
className="text-indigo-100 hover:text-white hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
GetIdBotLink: function GetIdBotLink(msg) {
return (
<a
href="https://telegram.me/get_id_bot"
className="text-indigo-100 hover:text-white hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
})}
</Alert>
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.agentenabled)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="enabled"
name="enabled"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="botAPI"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.botAPI)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="botAPI"
name="botAPI"
type="text"
placeholder={intl.formatMessage(messages.botAPI)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.botAPI && touched.botAPI && (
<div className="mt-2 text-red-500">{errors.botAPI}</div>
)}
</div>
<label
htmlFor="chatId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.chatId)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="chatId"
name="chatId"
type="text"
placeholder={intl.formatMessage(messages.chatId)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.chatId && touched.chatId && (
<div className="mt-2 text-red-500">{errors.chatId}</div>
)}
</div>
</div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)}
</Button>
</span>
</div>
</div>
</Form>
</>
);
}}
</Formik>
);
};
export default NotificationsTelegram;

View File

@@ -274,10 +274,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
} }
> >
<div className="mb-6"> <div className="mb-6">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="isDefault" htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.defaultserver)} {intl.formatMessage(messages.defaultserver)}
</label> </label>
@@ -290,10 +290,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.servername)} {intl.formatMessage(messages.servername)}
</label> </label>
@@ -318,10 +318,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="hostname" htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.hostname)} {intl.formatMessage(messages.hostname)}
</label> </label>
@@ -347,10 +347,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="port" htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.port)} {intl.formatMessage(messages.port)}
</label> </label>
@@ -371,10 +371,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="ssl" htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.ssl)} {intl.formatMessage(messages.ssl)}
</label> </label>
@@ -391,10 +391,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="apiKey" htmlFor="apiKey"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.apiKey)} {intl.formatMessage(messages.apiKey)}
</label> </label>
@@ -419,10 +419,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="baseUrl" htmlFor="baseUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.baseUrl)} {intl.formatMessage(messages.baseUrl)}
</label> </label>
@@ -447,10 +447,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="activeProfileId" htmlFor="activeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.qualityprofile)} {intl.formatMessage(messages.qualityprofile)}
</label> </label>
@@ -490,10 +490,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-8005">
<label <label
htmlFor="rootFolder" htmlFor="rootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.rootfolder)} {intl.formatMessage(messages.rootfolder)}
</label> </label>
@@ -531,10 +531,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="minimumAvailability" htmlFor="minimumAvailability"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.minimumAvailability)} {intl.formatMessage(messages.minimumAvailability)}
</label> </label>
@@ -560,10 +560,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="is4k" htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.server4k)} {intl.formatMessage(messages.server4k)}
</label> </label>

View File

@@ -18,6 +18,7 @@ const messages = defineMessages({
timezone: 'Timezone', timezone: 'Timezone',
supportoverseerr: 'Support Overseerr', supportoverseerr: 'Support Overseerr',
helppaycoffee: 'Help pay for coffee', helppaycoffee: 'Help pay for coffee',
documentation: 'Documentation',
}); });
const SettingsAbout: React.FC = () => { const SettingsAbout: React.FC = () => {
@@ -56,6 +57,16 @@ const SettingsAbout: React.FC = () => {
</div> </div>
<div className="mb-8"> <div className="mb-8">
<List title={intl.formatMessage(messages.gettingsupport)}> <List title={intl.formatMessage(messages.gettingsupport)}>
<List.Item title={intl.formatMessage(messages.documentation)}>
<a
href="https://docs.overseerr.dev"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
>
https://docs.overseerr.dev
</a>
</List.Item>
<List.Item title={intl.formatMessage(messages.githubdiscussions)}> <List.Item title={intl.formatMessage(messages.githubdiscussions)}>
<a <a
href="https://github.com/sct/overseerr/discussions" href="https://github.com/sct/overseerr/discussions"

View File

@@ -159,10 +159,10 @@ const SettingsMain: React.FC = () => {
return ( return (
<Form> <Form>
{userHasPermission(Permission.ADMIN) && ( {userHasPermission(Permission.ADMIN) && (
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="username" htmlFor="username"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.apikey)} {intl.formatMessage(messages.apikey)}
</label> </label>
@@ -203,10 +203,10 @@ const SettingsMain: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.applicationurl)} {intl.formatMessage(messages.applicationurl)}
</label> </label>

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord_white.svg'; import DiscordLogo from '../../assets/extlogos/discord_white.svg';
import SlackLogo from '../../assets/extlogos/slack.svg'; import SlackLogo from '../../assets/extlogos/slack.svg';
import TelegramLogo from '../../assets/extlogos/telegram.svg';
const messages = defineMessages({ const messages = defineMessages({
notificationsettings: 'Notification Settings', notificationsettings: 'Notification Settings',
@@ -65,6 +66,17 @@ const settingsRoutes: SettingsRoute[] = [
route: '/settings/notifications/slack', route: '/settings/notifications/slack',
regex: /^\/settings\/notifications\/slack/, regex: /^\/settings\/notifications\/slack/,
}, },
{
text: 'Telegram',
content: (
<span className="flex items-center">
<TelegramLogo className="h-4 mr-2" />
Telegram
</span>
),
route: '/settings/notifications/telegram',
regex: /^\/settings\/notifications\/telegram/,
},
]; ];
const SettingsNotifications: React.FC = ({ children }) => { const SettingsNotifications: React.FC = ({ children }) => {

View File

@@ -48,7 +48,7 @@ interface SyncStatus {
running: boolean; running: boolean;
progress: number; progress: number;
total: number; total: number;
currentLibrary: Library; currentLibrary?: Library;
libraries: Library[]; libraries: Library[];
} }
@@ -86,12 +86,17 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const syncLibraries = async () => { const syncLibraries = async () => {
setIsSyncing(true); setIsSyncing(true);
await axios.get('/api/v1/settings/plex/library', {
params: { const params: { sync: boolean; enable?: string } = {
sync: true, sync: true,
enable: };
activeLibraries.length > 0 ? activeLibraries.join(',') : undefined,
}, if (activeLibraries.length > 0) {
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/plex/library', {
params,
}); });
setIsSyncing(false); setIsSyncing(false);
revalidate(); revalidate();
@@ -118,13 +123,16 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const toggleLibrary = async (libraryId: string) => { const toggleLibrary = async (libraryId: string) => {
setIsSyncing(true); setIsSyncing(true);
if (activeLibraries.includes(libraryId)) { if (activeLibraries.includes(libraryId)) {
const params: { enable?: string } = {};
if (activeLibraries.length > 1) {
params.enable = activeLibraries
.filter((id) => id !== libraryId)
.join(',');
}
await axios.get('/api/v1/settings/plex/library', { await axios.get('/api/v1/settings/plex/library', {
params: { params,
enable:
activeLibraries.length > 0
? activeLibraries.filter((id) => id !== libraryId).join(',')
: undefined,
},
}); });
} else { } else {
await axios.get('/api/v1/settings/plex/library', { await axios.get('/api/v1/settings/plex/library', {
@@ -192,10 +200,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{submitError} {submitError}
</div> </div>
)} )}
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
<FormattedMessage {...messages.servername} /> <FormattedMessage {...messages.servername} />
</label> </label>
@@ -215,10 +223,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="hostname" htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
<FormattedMessage {...messages.hostname} /> <FormattedMessage {...messages.hostname} />
</label> </label>
@@ -240,10 +248,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="port" htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
<FormattedMessage {...messages.port} /> <FormattedMessage {...messages.port} />
</label> </label>
@@ -263,10 +271,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="ssl" htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.ssl)} {intl.formatMessage(messages.ssl)}
</label> </label>
@@ -369,6 +377,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
<div className="flex flex-col w-full sm:flex-row"> <div className="flex flex-col w-full sm:flex-row">
{dataSync?.running && ( {dataSync?.running && (
<> <>
{dataSync.currentLibrary && (
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2"> <div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<Badge> <Badge>
<FormattedMessage <FormattedMessage
@@ -377,17 +386,20 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
/> />
</Badge> </Badge>
</div> </div>
)}
<div className="flex items-center"> <div className="flex items-center">
<Badge badgeType="warning"> <Badge badgeType="warning">
<FormattedMessage <FormattedMessage
{...messages.librariesRemaining} {...messages.librariesRemaining}
values={{ values={{
count: dataSync.libraries.slice( count: dataSync.currentLibrary
? dataSync.libraries.slice(
dataSync.libraries.findIndex( dataSync.libraries.findIndex(
(library) => (library) =>
library.id === dataSync.currentLibrary.id library.id === dataSync.currentLibrary?.id
) + 1 ) + 1
).length, ).length
: 0,
}} }}
/> />
</Badge> </Badge>

View File

@@ -283,10 +283,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
} }
> >
<div className="mb-6"> <div className="mb-6">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="isDefault" htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.defaultserver)} {intl.formatMessage(messages.defaultserver)}
</label> </label>
@@ -299,10 +299,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="name" htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.servername)} {intl.formatMessage(messages.servername)}
</label> </label>
@@ -327,10 +327,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="hostname" htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.hostname)} {intl.formatMessage(messages.hostname)}
</label> </label>
@@ -356,10 +356,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="port" htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.port)} {intl.formatMessage(messages.port)}
</label> </label>
@@ -380,10 +380,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="ssl" htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.ssl)} {intl.formatMessage(messages.ssl)}
</label> </label>
@@ -400,10 +400,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="apiKey" htmlFor="apiKey"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.apiKey)} {intl.formatMessage(messages.apiKey)}
</label> </label>
@@ -428,10 +428,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="baseUrl" htmlFor="baseUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.baseUrl)} {intl.formatMessage(messages.baseUrl)}
</label> </label>
@@ -456,10 +456,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="activeProfileId" htmlFor="activeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.qualityprofile)} {intl.formatMessage(messages.qualityprofile)}
</label> </label>
@@ -499,10 +499,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="rootFolder" htmlFor="rootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.rootfolder)} {intl.formatMessage(messages.rootfolder)}
</label> </label>
@@ -540,10 +540,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="activeAnimeProfileId" htmlFor="activeAnimeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.animequalityprofile)} {intl.formatMessage(messages.animequalityprofile)}
</label> </label>
@@ -584,10 +584,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label <label
htmlFor="activeAnimeRootFolder" htmlFor="activeAnimeRootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.animerootfolder)} {intl.formatMessage(messages.animerootfolder)}
</label> </label>
@@ -626,10 +626,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="is4k" htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.server4k)} {intl.formatMessage(messages.server4k)}
</label> </label>
@@ -642,10 +642,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/> />
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label <label
htmlFor="enableSeasonFolders" htmlFor="enableSeasonFolders"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2" className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
> >
{intl.formatMessage(messages.seasonfolders)} {intl.formatMessage(messages.seasonfolders)}
</label> </label>

View File

@@ -43,27 +43,29 @@ const Setup: React.FC = () => {
}; };
return ( return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative"> <div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
<ImageFader <ImageFader
backgroundImages={[ backgroundImages={[
'/images/rotate1.jpg', '/images/rotate1.jpg',
'/images/rotate2.jpg', '/images/rotate2.jpg',
'/images/rotate3.jpg', '/images/rotate3.jpg',
'/images/rotate4.jpg', '/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]} ]}
/> />
<div className="absolute top-4 right-4 z-50"> <div className="absolute z-50 top-4 right-4">
<LanguagePicker /> <LanguagePicker />
</div> </div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-4xl relative z-40"> <div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-4xl">
<img <img
src="/logo.png" src="/logo.png"
className="mx-auto max-h-32 w-auto mb-10" className="w-auto mx-auto mb-10 max-h-32"
alt="Overseerr Logo" alt="Overseerr Logo"
/> />
<nav className="relative z-50"> <nav className="relative z-50">
<ul <ul
className=" bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md divide-y divide-gray-600 md:flex md:divide-y-0" className="bg-gray-800 bg-opacity-50 border border-gray-600 divide-y divide-gray-600 rounded-md md:flex md:divide-y-0"
style={{ backdropFilter: 'blur(5px)' }} style={{ backdropFilter: 'blur(5px)' }}
> >
<SetupSteps <SetupSteps
@@ -86,22 +88,22 @@ const Setup: React.FC = () => {
/> />
</ul> </ul>
</nav> </nav>
<div className="w-full mt-10 p-4 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md"> <div className="w-full p-4 mt-10 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md">
{currentStep === 1 && ( {currentStep === 1 && (
<LoginWithPlex onComplete={() => setCurrentStep(2)} /> <LoginWithPlex onComplete={() => setCurrentStep(2)} />
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div> <div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} /> <SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
<div className="mt-4 text-gray-500 text-sm"> <div className="mt-4 text-sm text-gray-500">
<span className="mr-2"> <span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge> <Badge>{intl.formatMessage(messages.tip)}</Badge>
</span> </span>
{intl.formatMessage(messages.syncingbackground)} {intl.formatMessage(messages.syncingbackground)}
</div> </div>
<div className="mt-8 border-t border-gray-700 pt-5"> <div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
disabled={!plexSettingsComplete} disabled={!plexSettingsComplete}
@@ -117,9 +119,9 @@ const Setup: React.FC = () => {
{currentStep === 3 && ( {currentStep === 3 && (
<div> <div>
<SettingsServices /> <SettingsServices />
<div className="mt-8 border-t border-gray-700 pt-5"> <div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="inline-flex ml-3 rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
onClick={() => finishSetup()} onClick={() => finishSetup()}

View File

@@ -160,7 +160,7 @@ const Slider: React.FC<SliderProps> = ({
return ( return (
<div className="relative"> <div className="relative">
<div className="absolute flex text-gray-400 right-0 -mt-10"> <div className="absolute right-0 flex -mt-10 text-gray-400">
<button <button
className={`${ className={`${
scrollPos.isStart ? 'cursor-not-allowed text-gray-800' : '' scrollPos.isStart ? 'cursor-not-allowed text-gray-800' : ''
@@ -207,14 +207,14 @@ const Slider: React.FC<SliderProps> = ({
</button> </button>
</div> </div>
<div <div
className="relative overflow-x-scroll whitespace-nowrap hide-scrollbar overscroll-x-contain -ml-4 -mr-4 px-2 overflow-y-auto" className="relative px-2 -ml-4 -mr-4 overflow-x-scroll overflow-y-auto whitespace-nowrap hide-scrollbar overscroll-x-contain"
ref={containerRef} ref={containerRef}
onScroll={onScroll} onScroll={onScroll}
> >
{items?.map((item, index) => ( {items?.map((item, index) => (
<div <div
key={`${sliderKey}-${index}`} key={`${sliderKey}-${index}`}
className="px-2 inline-block align-top" className="inline-block px-2 align-top"
> >
{item} {item}
</div> </div>
@@ -223,13 +223,13 @@ const Slider: React.FC<SliderProps> = ({
[...Array(10)].map((_item, i) => ( [...Array(10)].map((_item, i) => (
<div <div
key={`placeholder-${i}`} key={`placeholder-${i}`}
className="px-2 inline-block align-top" className="inline-block px-2 align-top"
> >
{placeholder} {placeholder}
</div> </div>
))} ))}
{isEmpty && ( {isEmpty && (
<div className="text-center text-white mt-16 mb-16"> <div className="mt-16 mb-16 text-center text-white">
{emptyMessage ? ( {emptyMessage ? (
emptyMessage emptyMessage
) : ( ) : (

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import Modal from '../Common/Modal';
import Transition from '../Transition';
const messages = defineMessages({
newversionavailable: 'New Version Available',
newversionDescription:
'An update is now available. Click the button below to reload the application.',
reloadOverseerr: 'Reload Overseerr',
});
const StatusChecker: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<{ version: string; commitTag: string }>(
'/api/v1/status',
{
refreshInterval: 60 * 1000,
}
);
if (!data && !error) {
return null;
}
if (!data) {
return null;
}
return (
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear
show={data.commitTag !== process.env.commitTag}
>
<Modal
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="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>
}
title={intl.formatMessage(messages.newversionavailable)}
onOk={() => location.reload()}
okText={intl.formatMessage(messages.reloadOverseerr)}
backgroundClickable={false}
>
{intl.formatMessage(messages.newversionDescription)}
</Modal>
</Transition>
);
};
export default StatusChecker;

View File

@@ -1,8 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import type { MediaType } from '../../../server/models/Search'; import type { MediaType } from '../../../server/models/Search';
import Available from '../../assets/available.svg';
import Requested from '../../assets/requested.svg';
import Unavailable from '../../assets/unavailable.svg';
import { withProperties } from '../../utils/typeHelpers'; import { withProperties } from '../../utils/typeHelpers';
import Transition from '../Transition'; import Transition from '../Transition';
import Placeholder from './Placeholder'; import Placeholder from './Placeholder';
@@ -11,6 +8,7 @@ import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal'; import RequestModal from '../RequestModal';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useIsTouch } from '../../hooks/useIsTouch'; import { useIsTouch } from '../../hooks/useIsTouch';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({ const messages = defineMessages({
movie: 'Movie', movie: 'Movie',
@@ -99,32 +97,63 @@ const TitleCard: React.FC<TitleCardProps> = ({
> >
<div className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden shadow-xl"> <div className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden shadow-xl">
<div <div
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${ className={`absolute left-2 top-2 rounded-md z-40 pointer-events-none ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600' mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
}`} }`}
> >
<div className="flex items-center h-4 px-2 py-1 text-xs font-normal text-center text-white uppercase"> <div className="flex items-center h-4 px-1 py-2 text-xs font-normal tracking-wider text-center text-white uppercase">
{mediaType === 'movie' {mediaType === 'movie'
? intl.formatMessage(messages.movie) ? intl.formatMessage(messages.movie)
: intl.formatMessage(messages.tvshow)} : intl.formatMessage(messages.tvshow)}
</div> </div>
</div> </div>
<div className="absolute z-40 pointer-events-none top-2 right-2">
<div
className="absolute top-0 right-0 z-40"
style={{
right: '-1px',
}}
>
{(currentStatus === MediaStatus.AVAILABLE || {(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && ( currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<Available className="rounded-tr-md" /> <div className="flex items-center justify-center w-4 h-4 text-white bg-green-400 border border-green-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
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 13l4 4L19 7"
/>
</svg>
</div>
)} )}
{currentStatus === MediaStatus.PENDING && ( {currentStatus === MediaStatus.PENDING && (
<Requested className="rounded-tr-md" /> <div className="flex items-center justify-center w-4 h-4 text-white bg-yellow-500 border border-yellow-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
</svg>
</div>
)} )}
{currentStatus === MediaStatus.PROCESSING && ( {currentStatus === MediaStatus.PROCESSING && (
<Unavailable className="rounded-tr-md" /> <div className="flex items-center justify-center w-4 h-4 text-white bg-indigo-500 border border-indigo-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
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-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
/>
</svg>
</div>
)} )}
</div> </div>
<Transition <Transition
@@ -173,7 +202,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
}} }}
> >
<div className="flex items-end w-full h-full"> <div className="flex items-end w-full h-full">
<div className="px-2 text-white pb-11"> <div
className={`px-2 text-white ${
currentStatus && currentStatus !== MediaStatus.UNKNOWN
? 'pb-2'
: 'pb-11'
}`}
>
{year && <div className="text-sm">{year}</div>} {year && <div className="text-sm">{year}</div>}
<h1 className="text-xl leading-tight whitespace-normal"> <h1 className="text-xl leading-tight whitespace-normal">
@@ -182,7 +217,11 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div <div
className="text-xs whitespace-normal" className="text-xs whitespace-normal"
style={{ style={{
WebkitLineClamp: 3, WebkitLineClamp:
currentStatus &&
currentStatus !== MediaStatus.UNKNOWN
? 5
: 3,
display: '-webkit-box', display: '-webkit-box',
overflow: 'hidden', overflow: 'hidden',
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
@@ -196,42 +235,16 @@ const TitleCard: React.FC<TitleCardProps> = ({
</Link> </Link>
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2"> <div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
<Link
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
>
<a className="flex w-full text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm cursor-pointer h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && ( {(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setShowRequestModal(true); setShowRequestModal(true);
}} }}
className="w-full ml-2 text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700" className="flex items-center justify-center w-full text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700"
> >
<svg <svg
className="w-4 mx-auto" className="w-4 mr-1"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -244,70 +257,9 @@ const TitleCard: React.FC<TitleCardProps> = ({
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/> />
</svg> </svg>
</button> <span className="text-xs">
)} {intl.formatMessage(globalMessages.request)}
{currentStatus === MediaStatus.PENDING && ( </span>
<button
className="w-full ml-2 text-center text-yellow-500 border border-yellow-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<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>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full ml-2 text-center text-indigo-500 border border-indigo-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
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>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full ml-2 text-center text-green-400 border border-green-400 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
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 13l4 4L19 7"
/>
</svg>
</button> </button>
)} )}
</div> </div>

View File

@@ -3,7 +3,7 @@ import type { ToastProps } from 'react-toast-notifications';
const Toast: React.FC<ToastProps> = ({ appearance, children, onDismiss }) => { const Toast: React.FC<ToastProps> = ({ appearance, children, onDismiss }) => {
return ( return (
<div className="toast flex items-end justify-center px-2 py-2 pointer-events-none sm:p-6 sm:items-start sm:justify-end"> <div className="toast flex items-end justify-center px-2 py-2 pointer-events-none sm:items-start sm:justify-end">
<div className="max-w-sm w-full bg-gray-700 shadow-lg rounded-lg pointer-events-auto"> <div className="max-w-sm w-full bg-gray-700 shadow-lg rounded-lg pointer-events-auto">
<div className="rounded-lg ring-1 ring-black ring-opacity-5 overflow-hidden"> <div className="rounded-lg ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4"> <div className="p-4">

View File

@@ -166,7 +166,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return ( return (
<div <div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 " className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{ style={{
height: 493, 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})`, 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})`,

View File

@@ -155,10 +155,10 @@ const UserEdit: React.FC = () => {
return ( return (
<> <>
<Header extraMargin={4}> <Header>
<FormattedMessage {...messages.edituser} /> <FormattedMessage {...messages.edituser} />
</Header> </Header>
<div className="px-4 space-y-6 sm:p-6 lg:pb-8"> <div className="space-y-6">
<div className="flex flex-col space-y-6 text-white lg:flex-row lg:space-y-0 lg:space-x-6"> <div className="flex flex-col space-y-6 text-white lg:flex-row lg:space-y-0 lg:space-x-6">
<div className="flex-grow space-y-6"> <div className="flex-grow space-y-6">
<div className="space-y-1"> <div className="space-y-1">
@@ -229,7 +229,7 @@ const UserEdit: React.FC = () => {
</div> </div>
</div> </div>
<div className="text-white"> <div className="text-white">
<div className="sm:border-t sm:border-gray-200 sm:pt-5"> <div className="sm:border-t sm:border-gray-200">
<div role="group" aria-labelledby="label-permissions"> <div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline"> <div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div> <div>

View File

@@ -150,7 +150,7 @@ const UserList: React.FC = () => {
</Modal> </Modal>
</Transition> </Transition>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header> <Header>{intl.formatMessage(messages.userlist)}</Header>
<Button <Button
className="mx-4 my-8" className="mx-4 my-8"
buttonType="primary" buttonType="primary"

View File

@@ -6,6 +6,7 @@ const globalMessages = defineMessages({
processing: 'Processing', processing: 'Processing',
unavailable: 'Unavailable', unavailable: 'Unavailable',
requested: 'Requested', requested: 'Requested',
request: 'Request',
failed: 'Failed', failed: 'Failed',
pending: 'Pending', pending: 'Pending',
declined: 'Declined', declined: 'Declined',

View File

@@ -60,6 +60,14 @@
"components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request", "components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer", "components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.",
"components.NotificationTypeSelector.mediaavailable": "Media Available",
"components.NotificationTypeSelector.mediaavailableDescription": "Sends a notification when media becomes available.",
"components.NotificationTypeSelector.mediafailed": "Media Failed",
"components.NotificationTypeSelector.mediafailedDescription": "Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.",
"components.NotificationTypeSelector.mediarequested": "Media Requested",
"components.NotificationTypeSelector.mediarequestedDescription": "Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.",
"components.PersonDetails.appearsin": "Appears in", "components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.crewmember": "Crew Member",
@@ -105,6 +113,7 @@
"components.RequestModal.status": "Status", "components.RequestModal.status": "Status",
"components.Search.searchresults": "Search Results", "components.Search.searchresults": "Search Results",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled",
"components.Settings.Notifications.NotificationsSlack.notificationtypes": "Notification Types",
"components.Settings.Notifications.NotificationsSlack.save": "Save Changes", "components.Settings.Notifications.NotificationsSlack.save": "Save Changes",
"components.Settings.Notifications.NotificationsSlack.saving": "Saving...", "components.Settings.Notifications.NotificationsSlack.saving": "Saving...",
"components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting up Slack Notifications", "components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting up Slack Notifications",
@@ -120,19 +129,29 @@
"components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates",
"components.Settings.Notifications.authPass": "Auth Pass", "components.Settings.Notifications.authPass": "Auth Pass",
"components.Settings.Notifications.authUser": "Auth User", "components.Settings.Notifications.authUser": "Auth User",
"components.Settings.Notifications.botAPI": "Bot API",
"components.Settings.Notifications.chatId": "Chat Id",
"components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.", "components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.",
"components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!", "components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!",
"components.Settings.Notifications.emailsender": "Email Sender Address", "components.Settings.Notifications.emailsender": "Email Sender Address",
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.", "components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!", "components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!",
"components.Settings.Notifications.enableSsl": "Enable SSL", "components.Settings.Notifications.enableSsl": "Enable SSL",
"components.Settings.Notifications.notificationtypes": "Notification Types",
"components.Settings.Notifications.save": "Save Changes", "components.Settings.Notifications.save": "Save Changes",
"components.Settings.Notifications.saving": "Saving…", "components.Settings.Notifications.saving": "Saving…",
"components.Settings.Notifications.senderName": "Sender Name",
"components.Settings.Notifications.settinguptelegram": "Setting up Telegram Notifications",
"components.Settings.Notifications.settinguptelegramDescription": "To setup Telegram you need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key. Additionally, you need the chat id for the chat you want the bot to send notifications to. You can do this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat or group chat.",
"components.Settings.Notifications.smtpHost": "SMTP Host", "components.Settings.Notifications.smtpHost": "SMTP Host",
"components.Settings.Notifications.smtpPort": "SMTP Port", "components.Settings.Notifications.smtpPort": "SMTP Port",
"components.Settings.Notifications.ssldisabletip": "SSL should be disabled on standard TLS connections (Port 587)", "components.Settings.Notifications.ssldisabletip": "SSL should be disabled on standard TLS connections (Port 587)",
"components.Settings.Notifications.telegramsettingsfailed": "Telegram notification settings failed to save.",
"components.Settings.Notifications.telegramsettingssaved": "Telegram notification settings saved!",
"components.Settings.Notifications.test": "Test", "components.Settings.Notifications.test": "Test",
"components.Settings.Notifications.testsent": "Test notification sent!", "components.Settings.Notifications.testsent": "Test notification sent!",
"components.Settings.Notifications.validationBotAPIRequired": "You must provide a Bot API key.",
"components.Settings.Notifications.validationChatIdRequired": "You must provide a Chat id.",
"components.Settings.Notifications.validationFromRequired": "You must provide an email sender address", "components.Settings.Notifications.validationFromRequired": "You must provide an email sender address",
"components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host", "components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host",
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port", "components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port",
@@ -186,6 +205,7 @@
"components.Settings.SettingsAbout.Releases.viewchangelog": "View Changelog", "components.Settings.SettingsAbout.Releases.viewchangelog": "View Changelog",
"components.Settings.SettingsAbout.Releases.viewongithub": "View on GitHub", "components.Settings.SettingsAbout.Releases.viewongithub": "View on GitHub",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.", "components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.",
"components.Settings.SettingsAbout.documentation": "Documentation",
"components.Settings.SettingsAbout.gettingsupport": "Getting Support", "components.Settings.SettingsAbout.gettingsupport": "Getting Support",
"components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions", "components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions",
"components.Settings.SettingsAbout.helppaycoffee": "Help pay for coffee", "components.Settings.SettingsAbout.helppaycoffee": "Help pay for coffee",
@@ -303,6 +323,9 @@
"components.Setup.tip": "Tip", "components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr", "components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results", "components.Slider.noresults": "No Results",
"components.StatusChacker.newversionDescription": "An update is now available. Click the button below to reload the application.",
"components.StatusChacker.newversionavailable": "New Version Available",
"components.StatusChacker.reloadOverseerr": "Reload Overseerr",
"components.TitleCard.movie": "Movie", "components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series", "components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
@@ -398,6 +421,7 @@
"i18n.partiallyavailable": "Partially Available", "i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending", "i18n.pending": "Pending",
"i18n.processing": "Processing…", "i18n.processing": "Processing…",
"i18n.request": "Request",
"i18n.requested": "Requested", "i18n.requested": "Requested",
"i18n.retry": "Retry", "i18n.retry": "Retry",
"i18n.tvshows": "Series", "i18n.tvshows": "Series",

View File

@@ -13,6 +13,7 @@ import { LanguageContext, AvailableLocales } from '../context/LanguageContext';
import Head from 'next/head'; import Head from 'next/head';
import Toast from '../components/Toast'; import Toast from '../components/Toast';
import { InteractionProvider } from '../context/InteractionContext'; import { InteractionProvider } from '../context/InteractionContext';
import StatusChecker from '../components/StatusChacker';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: string): Promise<any> => { const loadLocaleData = (locale: string): Promise<any> => {
@@ -74,7 +75,10 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
useEffect(() => { useEffect(() => {
loadLocaleData(currentLocale).then(setMessages); loadLocaleData(currentLocale).then(setMessages);
setCookie(null, 'locale', currentLocale, { path: '/' }); setCookie(null, 'locale', currentLocale, {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 10,
});
}, [currentLocale]); }, [currentLocale]);
if (router.pathname.match(/(login|setup)/)) { if (router.pathname.match(/(login|setup)/)) {
@@ -104,6 +108,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
<Head> <Head>
<title>Overseerr</title> <title>Overseerr</title>
</Head> </Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext> <UserContext initialUser={user}>{component}</UserContext>
</ToastProvider> </ToastProvider>
</InteractionProvider> </InteractionProvider>

View File

@@ -0,0 +1,17 @@
import { NextPage } from 'next';
import React from 'react';
import NotificationsTelegram from '../../../components/Settings/Notifications/NotificationsTelegram';
import SettingsLayout from '../../../components/Settings/SettingsLayout';
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
const NotificationsPage: NextPage = () => {
return (
<SettingsLayout>
<SettingsNotifications>
<NotificationsTelegram />
</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsPage;

View File

@@ -56,3 +56,7 @@ body {
code { code {
@apply px-2 py-1 bg-gray-800 rounded-md; @apply px-2 py-1 bg-gray-800 rounded-md;
} }
input[type='search']::-webkit-search-cancel-button {
-webkit-appearance: none;
}

View File

@@ -58,7 +58,7 @@ module.exports = {
}, },
}, },
variants: { variants: {
padding: ['first', 'last'], padding: ['first', 'last', 'responsive'],
borderWidth: ['first', 'last'], borderWidth: ['first', 'last'],
margin: ['first', 'last', 'responsive'], margin: ['first', 'last', 'responsive'],
boxShadow: ['group-focus'], boxShadow: ['group-focus'],