feat: add discover customization (#3182)
parent
f14d9407d8
commit
cd3574851a
@ -0,0 +1,151 @@
|
|||||||
|
describe('Discover Customization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show the discover customization settings', () => {
|
||||||
|
cy.visit('/settings');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customization]')
|
||||||
|
.should('contain', 'Discover Customization')
|
||||||
|
.scrollIntoView();
|
||||||
|
|
||||||
|
// There should be some built in options
|
||||||
|
cy.get('[data-testid=discover-option]').should('contain', 'Recently Added');
|
||||||
|
cy.get('[data-testid=discover-option]').should(
|
||||||
|
'contain',
|
||||||
|
'Recent Requests'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can drag to re-order elements and save to persist the changes', () => {
|
||||||
|
let dataTransfer = new DataTransfer();
|
||||||
|
cy.visit('/settings');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recent Requests');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a new discover option and remove it', () => {
|
||||||
|
cy.visit('/settings');
|
||||||
|
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||||
|
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||||
|
|
||||||
|
const sliderTitle = 'Custom Keyword Slider';
|
||||||
|
|
||||||
|
cy.get('#sliderType').select('TMDB Movie Keyword');
|
||||||
|
|
||||||
|
cy.get('#title').type(sliderTitle);
|
||||||
|
// First confirm that an invalid keyword doesn't allow us to submit anything
|
||||||
|
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
|
||||||
|
cy.wait('@searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]')
|
||||||
|
.find('button')
|
||||||
|
.should('be.disabled');
|
||||||
|
|
||||||
|
cy.get('#data').clear();
|
||||||
|
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
||||||
|
|
||||||
|
// Confirming we have some results
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]').submit();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Make sure its still there even if we reload
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Verify it's not rendering on our discover page (its still disabled!)
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('.slider-header').should('not.contain', sliderTitle);
|
||||||
|
|
||||||
|
cy.visit('/settings');
|
||||||
|
|
||||||
|
// Enable it, and check again
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.find('[role="checkbox"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.visit('/settings');
|
||||||
|
|
||||||
|
// let's delete it and confirm its deleted.
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.find('button')
|
||||||
|
.should('contain', 'Remove')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-option]')
|
||||||
|
.first()
|
||||||
|
.should('not.contain', sliderTitle);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,98 @@
|
|||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
|
||||||
|
export enum DiscoverSliderType {
|
||||||
|
RECENTLY_ADDED = 1,
|
||||||
|
RECENT_REQUESTS,
|
||||||
|
PLEX_WATCHLIST,
|
||||||
|
TRENDING,
|
||||||
|
POPULAR_MOVIES,
|
||||||
|
MOVIE_GENRES,
|
||||||
|
UPCOMING_MOVIES,
|
||||||
|
STUDIOS,
|
||||||
|
POPULAR_TV,
|
||||||
|
TV_GENRES,
|
||||||
|
UPCOMING_TV,
|
||||||
|
NETWORKS,
|
||||||
|
TMDB_MOVIE_KEYWORD,
|
||||||
|
TMDB_MOVIE_GENRE,
|
||||||
|
TMDB_TV_KEYWORD,
|
||||||
|
TMDB_TV_GENRE,
|
||||||
|
TMDB_SEARCH,
|
||||||
|
TMDB_STUDIO,
|
||||||
|
TMDB_NETWORK,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TRENDING,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.STUDIOS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.NETWORKS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
];
|
@ -0,0 +1,69 @@
|
|||||||
|
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class DiscoverSlider {
|
||||||
|
public static async bootstrapSliders(): Promise<void> {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
for (const slider of defaultSliders) {
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
type: slider.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSlider) {
|
||||||
|
logger.info('Creating built-in discovery slider', {
|
||||||
|
label: 'Discover Slider',
|
||||||
|
slider,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(new DiscoverSlider(slider));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public type: DiscoverSliderType;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public order: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isBuiltIn: boolean;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
public enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
// Title is not required for built in sliders because we will
|
||||||
|
// use translations for them.
|
||||||
|
public title?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public data?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscoverSlider;
|
@ -0,0 +1,15 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
|
||||||
|
name = 'AddDiscoverSlider1672041273674';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "discover_slider"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const discoverSettingRoutes = Router();
|
||||||
|
|
||||||
|
discoverSettingRoutes.post('/', async (req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const sliders = req.body as DiscoverSlider[];
|
||||||
|
|
||||||
|
if (!Array.isArray(sliders)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid request body.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < sliders.length; x++) {
|
||||||
|
const slider = sliders[x];
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: slider.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSlider && slider.id) {
|
||||||
|
existingSlider.enabled = slider.enabled;
|
||||||
|
existingSlider.order = x;
|
||||||
|
|
||||||
|
// Only allow changes to the following when the slider is not built in
|
||||||
|
if (!existingSlider.isBuiltIn) {
|
||||||
|
existingSlider.title = slider.title;
|
||||||
|
existingSlider.data = slider.data;
|
||||||
|
existingSlider.type = slider.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sliderRepository.save(existingSlider);
|
||||||
|
} else {
|
||||||
|
const newSlider = new DiscoverSlider({
|
||||||
|
isBuiltIn: false,
|
||||||
|
data: slider.data,
|
||||||
|
title: slider.title,
|
||||||
|
enabled: slider.enabled,
|
||||||
|
order: x,
|
||||||
|
type: slider.type,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(newSlider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(sliders);
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.post('/add', async (req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const slider = req.body as DiscoverSlider;
|
||||||
|
|
||||||
|
const newSlider = new DiscoverSlider({
|
||||||
|
isBuiltIn: false,
|
||||||
|
data: slider.data,
|
||||||
|
title: slider.title,
|
||||||
|
enabled: false,
|
||||||
|
order: -1,
|
||||||
|
type: slider.type,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(newSlider);
|
||||||
|
|
||||||
|
return res.json(newSlider);
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.get('/reset', async (_req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
await sliderRepository.clear();
|
||||||
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slider = await sliderRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.sliderId), isBuiltIn: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sliderRepository.remove(slider);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong deleting a slider.', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default discoverSettingRoutes;
|
@ -0,0 +1,38 @@
|
|||||||
|
type SlideCheckboxProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
checked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={false}
|
||||||
|
onClick={() => {
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
checked ? 'bg-indigo-500' : 'bg-gray-700'
|
||||||
|
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlideCheckbox;
|
@ -0,0 +1,68 @@
|
|||||||
|
import Header from '@app/components/Common/Header';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type { MovieResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
keywordMovies: '{keywordTitle} Movies',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverMovieKeyword = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
firstResultData,
|
||||||
|
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
|
||||||
|
`/api/v1/discover/movies`,
|
||||||
|
{
|
||||||
|
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.keywordMovies, {
|
||||||
|
keywordTitle: firstResultData?.keywords
|
||||||
|
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||||
|
.join(', '),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMovieKeyword;
|
@ -0,0 +1,68 @@
|
|||||||
|
import Header from '@app/components/Common/Header';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type { TvResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
keywordSeries: '{keywordTitle} Series',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverTvKeyword = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
firstResultData,
|
||||||
|
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
|
||||||
|
`/api/v1/discover/tv`,
|
||||||
|
{
|
||||||
|
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.keywordSeries, {
|
||||||
|
keywordTitle: firstResultData?.keywords
|
||||||
|
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||||
|
.join(', '),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTvKeyword;
|
@ -0,0 +1,79 @@
|
|||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
|
import { UserType, useUser } from '@app/hooks/useUser';
|
||||||
|
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||||
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
plexwatchlist: 'Your Plex Watchlist',
|
||||||
|
emptywatchlist:
|
||||||
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlexWatchlistSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: watchlistItems, error: watchlistError } = useSWR<{
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: WatchlistItem[];
|
||||||
|
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||||
|
revalidateOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
user?.userType !== UserType.PLEX ||
|
||||||
|
(watchlistItems &&
|
||||||
|
watchlistItems.results.length === 0 &&
|
||||||
|
!user?.settings?.watchlistSyncMovies &&
|
||||||
|
!user?.settings?.watchlistSyncTv) ||
|
||||||
|
watchlistError
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<Link href="/discover/watchlist">
|
||||||
|
<a className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||||
|
<ArrowCircleRightIcon />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="watchlist"
|
||||||
|
isLoading={!watchlistItems}
|
||||||
|
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
|
||||||
|
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
|
||||||
|
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
|
||||||
|
<a
|
||||||
|
href="https://support.plex.tv/articles/universal-watchlist/"
|
||||||
|
className="text-white transition duration-300 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
items={watchlistItems?.results.map((item) => (
|
||||||
|
<TmdbTitleCard
|
||||||
|
id={item.tmdbId}
|
||||||
|
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
type={item.mediaType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlexWatchlistSlider;
|
@ -0,0 +1,49 @@
|
|||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import RequestCard from '@app/components/RequestCard';
|
||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||||
|
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const RecentRequestsSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { data: requests, error: requestError } =
|
||||||
|
useSWR<RequestResultsResponse>(
|
||||||
|
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requests && requests.results.length === 0 && !requestError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<Link href="/requests?filter=all">
|
||||||
|
<a className="slider-title">
|
||||||
|
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
|
||||||
|
<ArrowCircleRightIcon />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="requests"
|
||||||
|
isLoading={!requests}
|
||||||
|
items={(requests?.results ?? []).map((request) => (
|
||||||
|
<RequestCard
|
||||||
|
key={`request-slider-item-${request.id}`}
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
placeholder={<RequestCard.Placeholder />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentRequestsSlider;
|
@ -0,0 +1,53 @@
|
|||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
recentlyAdded: 'Recently Added',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecentlyAddedSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||||
|
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
||||||
|
{ revalidateOnMount: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(media && !media.results.length && !mediaError) ||
|
||||||
|
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
|
||||||
|
type: 'or',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="media"
|
||||||
|
isLoading={!media}
|
||||||
|
items={(media?.results ?? []).map((item) => (
|
||||||
|
<TmdbTitleCard
|
||||||
|
key={`media-slider-item-${item.id}`}
|
||||||
|
id={item.id}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
tvdbId={item.tvdbId}
|
||||||
|
type={item.mediaType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentlyAddedSlider;
|
@ -0,0 +1,383 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import MediaSlider from '@app/components/MediaSlider';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
|
||||||
|
import type {
|
||||||
|
TmdbCompanySearchResponse,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
addSlider: 'Add Slider',
|
||||||
|
slidernameplaceholder: 'Slider Name',
|
||||||
|
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
|
||||||
|
providetmdbgenreid: 'Provide a TMDB Genre ID',
|
||||||
|
providetmdbsearch: 'Provide a search query',
|
||||||
|
providetmdbstudio: 'Provide TMDB Studio ID',
|
||||||
|
providetmdbnetwork: 'Provide TMDB Network ID',
|
||||||
|
addsuccess: 'Created new slider and saved discover customization settings.',
|
||||||
|
addfail: 'Failed to create new slider.',
|
||||||
|
needresults: 'You need to have at least 1 result to create a slider.',
|
||||||
|
validationDatarequired: 'You must provide a data value.',
|
||||||
|
validationTitlerequired: 'You must provide a title.',
|
||||||
|
addcustomslider: 'Add Custom Slider',
|
||||||
|
searchKeywords: 'Search keywords…',
|
||||||
|
seachGenres: 'Search genres…',
|
||||||
|
searchStudios: 'Search studios…',
|
||||||
|
starttyping: 'Starting typing to search.',
|
||||||
|
nooptions: 'No results.',
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateSliderProps = {
|
||||||
|
onCreate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateOption = {
|
||||||
|
type: DiscoverSliderType;
|
||||||
|
title: string;
|
||||||
|
dataUrl: string;
|
||||||
|
params?: string;
|
||||||
|
titlePlaceholderText: string;
|
||||||
|
dataPlaceholderText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateSlider = ({ onCreate }: CreateSliderProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const [resultCount, setResultCount] = useState(0);
|
||||||
|
|
||||||
|
const CreateSliderSchema = Yup.object().shape({
|
||||||
|
title: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationTitlerequired)
|
||||||
|
),
|
||||||
|
data: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationDatarequired)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateResultCount = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
setResultCount(count);
|
||||||
|
},
|
||||||
|
[setResultCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadKeywordOptions = debounce(async (inputValue: string) => {
|
||||||
|
const results = await axios.get<TmdbKeywordSearchResponse>(
|
||||||
|
'/api/v1/search/keyword',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query: encodeURIExtraParams(inputValue),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const loadCompanyOptions = debounce(async (inputValue: string) => {
|
||||||
|
const results = await axios.get<TmdbCompanySearchResponse>(
|
||||||
|
'/api/v1/search/company',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query: encodeURIExtraParams(inputValue),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const loadMovieGenreOptions = async () => {
|
||||||
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
|
'/api/v1/discover/genreslider/movie'
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTvGenreOptions = async () => {
|
||||||
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
|
'/api/v1/discover/genreslider/tv'
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: CreateOption[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
|
||||||
|
dataUrl: '/api/v1/discover/movies',
|
||||||
|
params: 'keywords=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_TV_KEYWORD,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
|
||||||
|
dataUrl: '/api/v1/discover/tv',
|
||||||
|
params: 'keywords=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
|
||||||
|
dataUrl: '/api/v1/discover/movies/genre/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_TV_GENRE,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
|
||||||
|
dataUrl: '/api/v1/discover/tv/genre/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_STUDIO,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbstudio),
|
||||||
|
dataUrl: '/api/v1/discover/movies/studio/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_NETWORK,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbnetwork),
|
||||||
|
dataUrl: '/api/v1/discover/tv/network/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_SEARCH,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbsearch),
|
||||||
|
dataUrl: '/api/v1/search',
|
||||||
|
params: 'query=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||||
|
title: '',
|
||||||
|
data: '',
|
||||||
|
}}
|
||||||
|
validationSchema={CreateSliderSchema}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={async (values, { resetForm }) => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/settings/discover/add', {
|
||||||
|
type: Number(values.sliderType),
|
||||||
|
title: values.title,
|
||||||
|
data: values.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.addsuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
onCreate();
|
||||||
|
resetForm();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.addfail), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
|
||||||
|
const activeOption = options.find(
|
||||||
|
(option) => option.type === Number(values.sliderType)
|
||||||
|
);
|
||||||
|
|
||||||
|
let dataInput: React.ReactNode;
|
||||||
|
|
||||||
|
switch (activeOption?.type) {
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key="keyword-select"
|
||||||
|
inputId="data"
|
||||||
|
isMulti
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
noOptionsMessage={({ inputValue }) =>
|
||||||
|
inputValue === ''
|
||||||
|
? intl.formatMessage(messages.starttyping)
|
||||||
|
: intl.formatMessage(messages.nooptions)
|
||||||
|
}
|
||||||
|
loadOptions={loadKeywordOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const keywords = value.map((item) => item.value).join(',');
|
||||||
|
|
||||||
|
setFieldValue('data', keywords);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key="movie-genre-select"
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadMovieGenreOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.seachGenres)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key="tv-genre-select"
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadTvGenreOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.seachGenres)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key="studio-select"
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadCompanyOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchStudios)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dataInput = (
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
name="data"
|
||||||
|
id="data"
|
||||||
|
placeholder={activeOption?.dataPlaceholderText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form data-testid="create-discover-option-form">
|
||||||
|
<div className="flex flex-col space-y-2 rounded border-2 border-dashed border-gray-700 bg-gray-800 px-2 py-2 text-gray-100">
|
||||||
|
<span className="text-overseerr text-xl font-semibold">
|
||||||
|
{intl.formatMessage(messages.addcustomslider)}
|
||||||
|
</span>
|
||||||
|
<Field as="select" id="sliderType" name="sliderType">
|
||||||
|
{options.map((option) => (
|
||||||
|
<option value={option.type} key={`type-${option.type}`}>
|
||||||
|
{option.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
id="title"
|
||||||
|
placeholder={activeOption?.titlePlaceholderText}
|
||||||
|
/>
|
||||||
|
{errors.title &&
|
||||||
|
touched.title &&
|
||||||
|
typeof errors.title === 'string' && (
|
||||||
|
<div className="error">{errors.title}</div>
|
||||||
|
)}
|
||||||
|
{dataInput}
|
||||||
|
{errors.data &&
|
||||||
|
touched.data &&
|
||||||
|
typeof errors.data === 'string' && (
|
||||||
|
<div className="error">{errors.data}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
{resultCount === 0 ? (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.needresults)}>
|
||||||
|
<div>
|
||||||
|
<Button buttonType="primary" buttonSize="sm" disabled>
|
||||||
|
{intl.formatMessage(messages.addSlider)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="sm"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.addSlider)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative px-4 pb-4">
|
||||||
|
{activeOption && values.title && values.data && (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`preview-${values.title}`}
|
||||||
|
title={values.title}
|
||||||
|
url={activeOption?.dataUrl.replace(
|
||||||
|
'$value',
|
||||||
|
encodeURIExtraParams(values.data)
|
||||||
|
)}
|
||||||
|
extraParams={activeOption.params?.replace(
|
||||||
|
'$value',
|
||||||
|
encodeURIExtraParams(values.data)
|
||||||
|
)}
|
||||||
|
onNewTitles={updateResultCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSlider;
|
@ -0,0 +1,170 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import { MenuIcon, XIcon } from '@heroicons/react/solid';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useDrag, useDrop } from 'react-aria';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
deletesuccess: 'Sucessfully deleted slider.',
|
||||||
|
deletefail: 'Failed to delete slider.',
|
||||||
|
remove: 'Remove',
|
||||||
|
enable: 'Toggle Visibility',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Position = {
|
||||||
|
None: 'None',
|
||||||
|
Above: 'Above',
|
||||||
|
Below: 'Below',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type DiscoverOptionProps = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
data?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isBuiltIn?: boolean;
|
||||||
|
onEnable: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onPositionUpdate: (
|
||||||
|
updatedItemId: number,
|
||||||
|
position: keyof typeof Position
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiscoverOption = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
onPositionUpdate,
|
||||||
|
onEnable,
|
||||||
|
subtitle,
|
||||||
|
data,
|
||||||
|
isBuiltIn,
|
||||||
|
onDelete,
|
||||||
|
}: DiscoverOptionProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
|
||||||
|
Position.None
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dragProps, isDragging } = useDrag({
|
||||||
|
getItems() {
|
||||||
|
return [{ id: id.toString(), title }];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSlider = async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/settings/discover/${id}`);
|
||||||
|
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
onDelete();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.deletefail), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { dropProps } = useDrop({
|
||||||
|
ref,
|
||||||
|
onDropMove: (e) => {
|
||||||
|
if (ref.current) {
|
||||||
|
const middlePoint = ref.current.offsetHeight / 2;
|
||||||
|
|
||||||
|
if (e.y < middlePoint) {
|
||||||
|
setHoverPosition(Position.Above);
|
||||||
|
} else {
|
||||||
|
setHoverPosition(Position.Below);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropExit: () => {
|
||||||
|
setHoverPosition(Position.None);
|
||||||
|
},
|
||||||
|
onDrop: async (e) => {
|
||||||
|
const items = await Promise.all(
|
||||||
|
e.items
|
||||||
|
.filter((item) => item.kind === 'text' && item.types.has('id'))
|
||||||
|
.map(async (item) => {
|
||||||
|
if (item.kind === 'text') {
|
||||||
|
return item.getText('id');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (items?.[0]) {
|
||||||
|
const dropped = Number(items[0]);
|
||||||
|
onPositionUpdate(dropped, hoverPosition);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
{...dragProps}
|
||||||
|
{...dropProps}
|
||||||
|
ref={ref}
|
||||||
|
data-testid="discover-option"
|
||||||
|
>
|
||||||
|
{hoverPosition === Position.Above && (
|
||||||
|
<div
|
||||||
|
className={`absolute -top-1 left-0 w-full border-t-2 border-indigo-500`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hoverPosition === Position.Below && (
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-1 left-0 w-full border-t-2 border-indigo-500`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`relative flex h-12 items-center space-x-2 rounded border border-gray-700 bg-gray-800 px-2 py-2 text-gray-100 ${
|
||||||
|
isDragging ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MenuIcon className="h-6 w-6" />
|
||||||
|
|
||||||
|
<span className="flex-1">{title}</span>
|
||||||
|
{subtitle && <Badge>{subtitle}</Badge>}
|
||||||
|
{data && <Badge badgeType="warning">{data}</Badge>}
|
||||||
|
{!isBuiltIn && (
|
||||||
|
<div className="px-2">
|
||||||
|
<Button
|
||||||
|
buttonType="danger"
|
||||||
|
buttonSize="sm"
|
||||||
|
onClick={() => deleteSlider()}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span>{intl.formatMessage(messages.remove)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Tooltip content={intl.formatMessage(messages.enable)}>
|
||||||
|
<div>
|
||||||
|
<SlideCheckbox
|
||||||
|
onClick={() => {
|
||||||
|
onEnable();
|
||||||
|
}}
|
||||||
|
checked={enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverOption;
|
@ -0,0 +1,220 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import CreateSlider from '@app/components/Settings/SettingsMain/DiscoverCustomization/CreateSlider';
|
||||||
|
import DiscoverOption from '@app/components/Settings/SettingsMain/DiscoverCustomization/DiscoverOption';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { RefreshIcon, SaveIcon } from '@heroicons/react/solid';
|
||||||
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
resettodefault: 'Reset to Default',
|
||||||
|
resetwarning:
|
||||||
|
'Reset all sliders to default. This will also delete any custom sliders!',
|
||||||
|
updatesuccess: 'Updated discover customization settings.',
|
||||||
|
updatefailed:
|
||||||
|
'Something went wrong updating the discover customization settings.',
|
||||||
|
resetsuccess: 'Sucessfully reset discover customization settings.',
|
||||||
|
resetfailed:
|
||||||
|
'Something went wrong resetting the discover customization settings.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverCustomization = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const { data, error, mutate } = useSWR<DiscoverSlider[]>(
|
||||||
|
'/api/v1/settings/discover'
|
||||||
|
);
|
||||||
|
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
|
||||||
|
|
||||||
|
// We need to sync the state here so that we can modify the changes locally without commiting
|
||||||
|
// anything to the server until the user decides to save the changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setSliders(data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const updateSliders = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/settings/discover', sliders);
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.updatesuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
mutate();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.updatefailed), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSliders = async () => {
|
||||||
|
try {
|
||||||
|
await axios.get('/api/v1/settings/discover/reset');
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.resetsuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
mutate();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.resetfailed), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanged = () => !Object.is(data, sliders);
|
||||||
|
|
||||||
|
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
|
||||||
|
if (slider.title) {
|
||||||
|
return slider.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (slider.type) {
|
||||||
|
case DiscoverSliderType.RECENTLY_ADDED:
|
||||||
|
return intl.formatMessage(sliderTitles.recentlyAdded);
|
||||||
|
case DiscoverSliderType.RECENT_REQUESTS:
|
||||||
|
return intl.formatMessage(sliderTitles.recentrequests);
|
||||||
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||||
|
return intl.formatMessage(sliderTitles.plexwatchlist);
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
return intl.formatMessage(sliderTitles.trending);
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
return intl.formatMessage(sliderTitles.popularmovies);
|
||||||
|
case DiscoverSliderType.MOVIE_GENRES:
|
||||||
|
return intl.formatMessage(sliderTitles.moviegenres);
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
return intl.formatMessage(sliderTitles.upcoming);
|
||||||
|
case DiscoverSliderType.STUDIOS:
|
||||||
|
return intl.formatMessage(sliderTitles.studios);
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
return intl.formatMessage(sliderTitles.populartv);
|
||||||
|
case DiscoverSliderType.TV_GENRES:
|
||||||
|
return intl.formatMessage(sliderTitles.tvgenres);
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
return intl.formatMessage(sliderTitles.upcomingtv);
|
||||||
|
case DiscoverSliderType.NETWORKS:
|
||||||
|
return intl.formatMessage(sliderTitles.networks);
|
||||||
|
default:
|
||||||
|
return 'Unknown Slider';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSliderSubtitle = (
|
||||||
|
slider: Partial<DiscoverSlider>
|
||||||
|
): string | undefined => {
|
||||||
|
switch (slider.type) {
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbtvgenre);
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbstudio);
|
||||||
|
case DiscoverSliderType.TMDB_NETWORK:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||||
|
case DiscoverSliderType.TMDB_SEARCH:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="section">
|
||||||
|
<div className="flex flex-col space-y-2 rounded border border-gray-700 p-2">
|
||||||
|
{sliders.map((slider, index) => (
|
||||||
|
<DiscoverOption
|
||||||
|
id={slider.id ?? -1}
|
||||||
|
key={slider.id ?? `no-id-${index}`}
|
||||||
|
title={getSliderTitle(slider)}
|
||||||
|
subtitle={getSliderSubtitle(slider)}
|
||||||
|
data={slider.data}
|
||||||
|
enabled={slider.enabled}
|
||||||
|
isBuiltIn={slider.isBuiltIn}
|
||||||
|
onDelete={() => {
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
onEnable={() => {
|
||||||
|
const tempSliders = sliders.slice();
|
||||||
|
tempSliders[index].enabled = !tempSliders[index].enabled;
|
||||||
|
setSliders(tempSliders);
|
||||||
|
}}
|
||||||
|
onPositionUpdate={(updatedItemId, position) => {
|
||||||
|
const originalPosition = sliders.findIndex(
|
||||||
|
(item) => item.id === updatedItemId
|
||||||
|
);
|
||||||
|
const originalItem = sliders[originalPosition];
|
||||||
|
|
||||||
|
const tempSliders = sliders.slice();
|
||||||
|
|
||||||
|
tempSliders.splice(originalPosition, 1);
|
||||||
|
tempSliders.splice(
|
||||||
|
position === 'Above' && index > originalPosition
|
||||||
|
? Math.max(index - 1, 0)
|
||||||
|
: index,
|
||||||
|
0,
|
||||||
|
originalItem
|
||||||
|
);
|
||||||
|
|
||||||
|
setSliders(tempSliders);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<CreateSlider
|
||||||
|
onCreate={() => {
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
|
||||||
|
<Button buttonType="default" onClick={() => resetSliders()}>
|
||||||
|
<RefreshIcon />
|
||||||
|
<span>{intl.formatMessage(messages.resettodefault)}</span>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanged()}
|
||||||
|
onClick={() => updateSliders()}
|
||||||
|
data-testid="discover-customize-submit"
|
||||||
|
>
|
||||||
|
<SaveIcon />
|
||||||
|
<span>{intl.formatMessage(globalMessages.save)}</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverCustomization;
|
@ -0,0 +1,8 @@
|
|||||||
|
import DiscoverMovieKeyword from '@app/components/Discover/DiscoverMovieKeyword';
|
||||||
|
import type { NextPage } from 'next';
|
||||||
|
|
||||||
|
const DiscoverMoviesKeywordPage: NextPage = () => {
|
||||||
|
return <DiscoverMovieKeyword />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMoviesKeywordPage;
|
@ -0,0 +1,8 @@
|
|||||||
|
import DiscoverTvKeyword from '@app/components/Discover/DiscoverTvKeyword';
|
||||||
|
import type { NextPage } from 'next';
|
||||||
|
|
||||||
|
const DiscoverTvKeywordPage: NextPage = () => {
|
||||||
|
return <DiscoverTvKeyword />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTvKeywordPage;
|
Loading…
Reference in new issue