@ -0,0 +1,21 @@
|
||||
# Discord
|
||||
|
||||
## Configuration
|
||||
|
||||
{% hint style="info" %}
|
||||
In order to configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
||||
|
||||
In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured in their user settings.
|
||||
{% endhint %}
|
||||
|
||||
### Bot Username (optional)
|
||||
|
||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||
|
||||
### Bot Avatar URL (optional)
|
||||
|
||||
Similar to the bot username, you can override the avatar for your bot.
|
||||
|
||||
### Webhook URL
|
||||
|
||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
@ -0,0 +1,54 @@
|
||||
# Email
|
||||
|
||||
{% hint style="info" %}
|
||||
The following email notification types are sent to _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
- Media Failed
|
||||
|
||||
On the other hand, the email notification types below are only sent to the user who submitted the request:
|
||||
|
||||
- Media Approved
|
||||
- Media Declined
|
||||
- Media Available
|
||||
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### Sender Address (required)
|
||||
|
||||
Set this to the email address you would like to appear in the "from" field of the email message.
|
||||
|
||||
Depending on your email provider, this may need to be an address you own. For example, Gmail requires this to be your actual email address.
|
||||
|
||||
### Sender Name (optional)
|
||||
|
||||
Configure a friendly name for the email sender.
|
||||
|
||||
### SMTP Host
|
||||
|
||||
Set this to the hostname or IP address of your SMTP host/server.
|
||||
|
||||
### SMTP Port
|
||||
|
||||
Set this to a supported port number for your SMTP host. `465` and `587` are commonly used.
|
||||
|
||||
### Enable SSL (optional)
|
||||
|
||||
This setting should only be enabled for ports that use [implicit SSL/TLS](https://tools.ietf.org/html/rfc8314) (e.g., port `465` in most cases).
|
||||
|
||||
For servers that support [opportunistic TLS/STARTTLS](https://en.wikipedia.org/wiki/Opportunistic_TLS) (typically via port `587`), this setting should **not** be enabled.
|
||||
|
||||
### SMTP Username & Password
|
||||
|
||||
{% hint style="info" %}
|
||||
If your account has two-factor authentication enabled, you may need to create an application password instead of using your account password.
|
||||
{% endhint %}
|
||||
|
||||
Configure these values as appropriate to authenticate with your SMTP host.
|
||||
|
||||
### PGP Private Key & Password (optional)
|
||||
|
||||
Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their PGP public keys enabled in their user settings in order for PGP encryption to be used.
|
@ -0,0 +1,195 @@
|
||||
# Settings
|
||||
|
||||
## General
|
||||
|
||||
### API Key
|
||||
|
||||
This is your Overseerr API key, which can be used to integrate Overseerr with third-party applications. Do **not** share this key publicly, as it can be used to gain administrator access!
|
||||
|
||||
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
||||
|
||||
### Application Title
|
||||
|
||||
If you aren't a huge fan of the name "Overseerr" and would like to display something different to your users, you can customize the application title!
|
||||
|
||||
### Application URL
|
||||
|
||||
Set this to the externally-accessible URL of your Overseerr instance. If configured, [notifications](../notifications/README.md) will include links!
|
||||
|
||||
### Enable Proxy Support
|
||||
|
||||
If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy-examples.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html).
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
### Enable CSRF Protection
|
||||
|
||||
{% hint style="danger" %}
|
||||
**This is an advanced setting.** We do not recommend enabling it unless you understand the implications of doing so.
|
||||
{% endhint %}
|
||||
|
||||
CSRF stands for **Cross-Site Request Forgery**. When this setting is enabled, all external API access that alters Overseerr application data is blocked.
|
||||
|
||||
If you do not use Overseerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
|
||||
|
||||
One caveat, however, is that **HTTPS is required**, meaning that once this setting is enabled, you will no longer be able to access your Overseerr instance over HTTP (including using an IP address and port number).
|
||||
|
||||
If you enable this setting and find yourself unable to access Overseerr, you can disable the setting by modifying `settings.json` in `/app/config`.
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
### Enable Image Caching
|
||||
|
||||
{% hint style="danger" %}
|
||||
**This feature is experimental.** Enable it at your own risk!
|
||||
{% endhint %}
|
||||
|
||||
When enabled, all images (including media posters from TMDb) will be cached locally on your server. Images will also be optimized for client devices; i.e., if you access Overseerr using a mobile device, smaller versions will be served compared to when accessing Overseerr on desktop.
|
||||
|
||||
Note that this feature requires and will use a significant amount of disk space, and there is currently no automated deletion of old or expired images. If running Overseerr using Docker, it is possible to manually clear the image cache by simply removing and recreating the container.
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
### Discover Region & Discover Language
|
||||
|
||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
|
||||
|
||||
### Hide Available Media
|
||||
|
||||
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
||||
|
||||
Available media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title.
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
### Allow Partial Series Requests
|
||||
|
||||
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Users
|
||||
|
||||
### Enable Local User Sign-In
|
||||
|
||||
When enabled, users who have configured passwords will be allowed to sign in using their email address.
|
||||
|
||||
When disabled, Plex OAuth becomes the only sign-in option, and any "local users" you have created will not be able to sign in to Overseerr.
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
### Default User Permissions
|
||||
|
||||
Select the permissions you would like new users to have by default. It is important to set these, as any user with access to your Plex server will be able to log in to Overseerr, and they will be granted the permissions you select here.
|
||||
|
||||
## Plex
|
||||
|
||||
### Plex Settings
|
||||
|
||||
{% hint style="info" %}
|
||||
To set up Plex, you can either enter your details manually or select a server retrieved from [plex.tv](https://plex.tv/). Press the button to the right of the "Server" dropdown to retrieve available servers.
|
||||
|
||||
Depending on your setup/configuration, you may need to enter your Plex server details manually in order to establish a connection from Overseerr.
|
||||
{% endhint %}
|
||||
|
||||
#### Server Name
|
||||
|
||||
This value is automatically retrieved from Plex, and cannot be edited manually in Overseerr.
|
||||
|
||||
#### Hostname or IP Address
|
||||
|
||||
If you have Overseerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`).
|
||||
|
||||
#### Port
|
||||
|
||||
This value should be set to the port that your Plex server listens on. The default port that Plex uses is `32400`, but you may need to set this to `443` or some other value if your Plex server is hosted on a VPS or cloud provider.
|
||||
|
||||
#### SSL
|
||||
|
||||
Tick this box to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported.
|
||||
|
||||
### Plex Libraries
|
||||
|
||||
In this section, simply select the libraries you would like Overseerr to scan. Overseerr will periodically check the selected libraries for available content to update the media status that is displayed to users.
|
||||
|
||||
If you do not see your Plex libraries listed, verify your Plex settings and then click the "Scan Plex Libraries" button.
|
||||
|
||||
### Manual Library Scan
|
||||
|
||||
Overseerr will perform a full scan of your Plex libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Plex, a one-time full manual library scan is recommended!
|
||||
|
||||
## Services
|
||||
|
||||
{% hint style="info" %}
|
||||
If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Overseerr.
|
||||
|
||||
Overseerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media_.
|
||||
|
||||
If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.
|
||||
{% endhint %}
|
||||
|
||||
### Radarr/Sonarr Settings
|
||||
|
||||
#### Default Server
|
||||
|
||||
At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr.
|
||||
|
||||
If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K servers _in addition to_ default non-4K servers.
|
||||
|
||||
#### 4K Server
|
||||
|
||||
Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do **not** check this box!
|
||||
|
||||
#### Server Name
|
||||
|
||||
Enter a friendly name for the Radarr/Sonarr server.
|
||||
|
||||
#### Hostname or IP Address
|
||||
|
||||
If you have Overseerr installed on the same network as Radarr/Sonarr, you can set this to the local IP address of your Radarr/Sonarr server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`).
|
||||
|
||||
#### Port
|
||||
|
||||
This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider.
|
||||
|
||||
#### SSL
|
||||
|
||||
Tick this box to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported.
|
||||
|
||||
#### API Key
|
||||
|
||||
Enter your Radarr/Sonarr API key here. Do **not** share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers!
|
||||
|
||||
You can locate the required API keys in Radarr/Sonarr in **Settings → General → Security**.
|
||||
|
||||
#### Base URL
|
||||
|
||||
If you have configured a base URL for Radarr/Sonarr, you **must** enter it here in order for Overseerr to connect to those services!
|
||||
|
||||
You can verify whether or not you have a base URL configured in Radarr/Sonarr in **Settings → General → Host**. (Note that a restart of your Radarr/Sonarr servers is required if you modify this setting!)
|
||||
|
||||
#### Profiles, Root Folder, Minimum Availability
|
||||
|
||||
Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured!
|
||||
|
||||
#### External URL
|
||||
|
||||
If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages.
|
||||
|
||||
#### Enable Scan
|
||||
|
||||
Tick this box if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
|
||||
|
||||
#### Disable Auto-Search
|
||||
|
||||
If you do not want Radarr/Sonarr to automatically search for media upon submission of a request, you can disable this setting.
|
||||
|
||||
## Notifications
|
||||
|
||||
Please see [Notifications](../notifications/README.md) for details on configuring and enabling notifications.
|
||||
|
||||
## Jobs & Cache
|
||||
|
||||
Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered on this page.
|
||||
|
||||
Overseerr also caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls. If necessary, the cache for any particular endpoint can be cleared by clicking the "Flush Cache" button.
|
@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['tailwindcss', 'postcss-preset-env'],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 43 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 45 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 969 B |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1,5 @@
|
||||
export interface GenreSliderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
backdrops: string[];
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserQuotaFields1616576677254 implements MigrationInterface {
|
||||
name = 'AddUserQuotaFields1616576677254';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 992 B After Width: | Height: | Size: 639 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 524 B After Width: | Height: | Size: 399 B |
@ -0,0 +1,18 @@
|
||||
import Image, { ImageProps } from 'next/image';
|
||||
import React from 'react';
|
||||
import useSettings from '../../../hooks/useSettings';
|
||||
|
||||
/**
|
||||
* The CachedImage component should be used wherever
|
||||
* we want to offer the option to locally cache images.
|
||||
*
|
||||
* It uses the `next/image` Image component but overrides
|
||||
* the `unoptimized` prop based on the application setting `cacheImages`.
|
||||
**/
|
||||
const CachedImage: React.FC<ImageProps> = (props) => {
|
||||
const { currentSettings } = useSettings();
|
||||
|
||||
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
|
||||
};
|
||||
|
||||
export default CachedImage;
|
@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface ProgressCircleProps {
|
||||
className?: string;
|
||||
progress?: number;
|
||||
useHeatLevel?: boolean;
|
||||
}
|
||||
|
||||
const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
className,
|
||||
progress = 0,
|
||||
useHeatLevel,
|
||||
}) => {
|
||||
const ref = useRef<SVGCircleElement>(null);
|
||||
|
||||
let color = '';
|
||||
let emptyColor = 'text-gray-300';
|
||||
|
||||
if (useHeatLevel) {
|
||||
color = 'text-green-500';
|
||||
|
||||
if (progress <= 50) {
|
||||
color = 'text-yellow-500';
|
||||
}
|
||||
|
||||
if (progress <= 10) {
|
||||
color = 'text-red-500';
|
||||
}
|
||||
|
||||
if (progress === 0) {
|
||||
emptyColor = 'text-red-600';
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && ref.current) {
|
||||
const radius = ref.current?.r.baseVal.value;
|
||||
const circumference = (radius ?? 0) * 2 * Math.PI;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
ref.current.style.strokeDashoffset = `${offset}`;
|
||||
ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<svg className={`${className} ${color}`} viewBox="0 0 24 24">
|
||||
<circle
|
||||
className={`${emptyColor} opacity-30`}
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
style={{
|
||||
transition: '0.35s stroke-dashoffset',
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
ref={ref}
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressCircle;
|
@ -0,0 +1,56 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
moviegenres: 'Movie Genres',
|
||||
});
|
||||
|
||||
const MovieGenreList: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.moviegenres)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{intl.formatMessage(messages.moviegenres)}</Header>
|
||||
</div>
|
||||
<ul className="cards-horizontal">
|
||||
{data.map((genre, index) => (
|
||||
<li key={`genre-${genre.id}-${index}`}>
|
||||
<GenreCard
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieGenreList;
|
@ -0,0 +1,70 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import Link from 'next/link';
|
||||
|
||||
const messages = defineMessages({
|
||||
moviegenres: 'Movie Genres',
|
||||
});
|
||||
|
||||
const MovieGenreSlider: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie?language=${locale}`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/movies/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="movie-genres"
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={(data ?? []).map((genre, index) => (
|
||||
<GenreCard
|
||||
key={`genre-${genre.id}-${index}`}
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MovieGenreSlider);
|
@ -0,0 +1,56 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
seriesgenres: 'Series Genres',
|
||||
});
|
||||
|
||||
const TvGenreList: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.seriesgenres)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{intl.formatMessage(messages.seriesgenres)}</Header>
|
||||
</div>
|
||||
<ul className="cards-horizontal">
|
||||
{data.map((genre, index) => (
|
||||
<li key={`genre-${genre.id}-${index}`}>
|
||||
<GenreCard
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TvGenreList;
|
@ -0,0 +1,70 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import Link from 'next/link';
|
||||
|
||||
const messages = defineMessages({
|
||||
tvgenres: 'Series Genres',
|
||||
});
|
||||
|
||||
const TvGenreSlider: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv?language=${locale}`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/tv/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="tv-genres"
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={(data ?? []).map((genre, index) => (
|
||||
<GenreCard
|
||||
key={`genre-tv-${genre.id}-${index}`}
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TvGenreSlider);
|
@ -0,0 +1,63 @@
|
||||
type AvailableColors =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'darkred'
|
||||
| 'blue'
|
||||
| 'lightblue'
|
||||
| 'darkblue'
|
||||
| 'orange'
|
||||
| 'darkorange'
|
||||
| 'green'
|
||||
| 'lightgreen'
|
||||
| 'purple'
|
||||
| 'darkpurple'
|
||||
| 'yellow'
|
||||
| 'pink';
|
||||
|
||||
export const colorTones: Record<AvailableColors, [string, string]> = {
|
||||
red: ['991B1B', 'FCA5A5'],
|
||||
darkred: ['1F2937', 'F87171'],
|
||||
blue: ['032541', '01b4e4'],
|
||||
lightblue: ['1F2937', '60A5FA'],
|
||||
darkblue: ['1F2937', '2864d2'],
|
||||
orange: ['92400E', 'FCD34D'],
|
||||
lightgreen: ['065F46', '6EE7B7'],
|
||||
green: ['087d29', '21cb51'],
|
||||
purple: ['5B21B6', 'C4B5FD'],
|
||||
yellow: ['777e0d', 'e4ed55'],
|
||||
darkorange: ['552c01', 'd47c1d'],
|
||||
black: ['1F2937', 'D1D5DB'],
|
||||
pink: ['9D174D', 'F9A8D4'],
|
||||
darkpurple: ['480c8b', 'a96bef'],
|
||||
};
|
||||
|
||||
export const genreColorMap: Record<number, [string, string]> = {
|
||||
0: colorTones.black,
|
||||
28: colorTones.red, // Action
|
||||
12: colorTones.darkpurple, // Adventure
|
||||
16: colorTones.blue, // Animation
|
||||
35: colorTones.orange, // Comedy
|
||||
80: colorTones.darkblue, // Crime
|
||||
99: colorTones.lightgreen, // Documentary
|
||||
18: colorTones.pink, // Drama
|
||||
10751: colorTones.yellow, // Family
|
||||
14: colorTones.lightblue, // Fantasy
|
||||
36: colorTones.orange, // History
|
||||
27: colorTones.black, // Horror
|
||||
10402: colorTones.blue, // Music
|
||||
9648: colorTones.purple, // Mystery
|
||||
10749: colorTones.pink, // Romance
|
||||
878: colorTones.lightblue, // Science Fiction
|
||||
10770: colorTones.red, // TV Movie
|
||||
53: colorTones.black, // Thriller
|
||||
10752: colorTones.darkred, // War
|
||||
37: colorTones.orange, // Western
|
||||
10759: colorTones.darkpurple, // Action & Adventure
|
||||
10762: colorTones.blue, // Kids
|
||||
10763: colorTones.black, // News
|
||||
10764: colorTones.darkorange, // Reality
|
||||
10765: colorTones.lightblue, // Sci-Fi & Fantasy
|
||||
10766: colorTones.pink, // Soap
|
||||
10767: colorTones.lightgreen, // Talk
|
||||
10768: colorTones.darkred, // War & Politics
|
||||
};
|
@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { withProperties } from '../../utils/typeHelpers';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
|
||||
interface GenreCardProps {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const GenreCard: React.FC<GenreCardProps> = ({
|
||||
image,
|
||||
url,
|
||||
name,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={url}>
|
||||
<a
|
||||
className={`relative flex items-center justify-center h-32 sm:h-36 ${
|
||||
canExpand ? 'w-full' : 'w-56 sm:w-72'
|
||||
} p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
|
||||
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
|
||||
} rounded-xl bg-cover bg-center overflow-hidden`}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
|
||||
<div
|
||||
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
|
||||
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
|
||||
}`}
|
||||
/>
|
||||
<div className="relative z-20 w-full text-2xl font-bold text-center text-white truncate whitespace-normal sm:text-3xl">
|
||||
{name}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const GenreCardPlaceholder: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className={`relative h-32 w-56 sm:h-40 sm:w-72 animate-pulse rounded-xl bg-gray-700`}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder });
|