commit
e9545d44a4
@ -0,0 +1,5 @@
|
||||
organization: overseerr
|
||||
defaultSticker: clcyagj1j329008l468ya8pu2
|
||||
stickers:
|
||||
- id: clcyagj1j329008l468ya8pu2
|
||||
alias: overseerr-contributor
|
@ -0,0 +1,41 @@
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
schedule:
|
||||
- cron: '50 7 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
@ -0,0 +1,163 @@
|
||||
describe('Discover Customization', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||
});
|
||||
|
||||
it('show the discover customization settings', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=create-slider-header')
|
||||
.should('contain', 'Create New Slider')
|
||||
.scrollIntoView();
|
||||
|
||||
// There should be some built in options
|
||||
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||
'contain',
|
||||
'Recently Added'
|
||||
);
|
||||
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||
'contain',
|
||||
'Recent Requests'
|
||||
);
|
||||
});
|
||||
|
||||
it('can drag to re-order elements and save to persist the changes', () => {
|
||||
let dataTransfer = new DataTransfer();
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('drop', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.should('contain', 'Recently Added');
|
||||
|
||||
cy.get('[data-testid=discover-customize-submit').click();
|
||||
cy.wait('@getDiscoverSliders');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
dataTransfer = new DataTransfer();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.should('contain', 'Recently Added');
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('drop', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.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('/');
|
||||
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
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('christmas{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-slider-edit-mode]')
|
||||
.first()
|
||||
.should('contain', sliderTitle);
|
||||
|
||||
// Make sure its still there even if we reload
|
||||
cy.reload();
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.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.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
// Enable it, and check again
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.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.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
// let's delete it and confirm its deleted.
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.find('[data-testid=discover-slider-remove-button]')
|
||||
.click();
|
||||
|
||||
cy.wait('@discoverSlider');
|
||||
cy.wait('@getDiscoverSliders');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.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,6 @@
|
||||
const clearCookies: Middleware = (_req, res, next) => {
|
||||
res.removeHeader('Set-Cookie');
|
||||
next();
|
||||
};
|
||||
|
||||
export default clearCookies;
|
@ -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,131 @@
|
||||
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.put('/:sliderId', async (req, res, next) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const slider = req.body as DiscoverSlider;
|
||||
|
||||
try {
|
||||
const existingSlider = await sliderRepository.findOneOrFail({
|
||||
where: {
|
||||
id: Number(req.params.sliderId),
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
return res.status(200).json(existingSlider);
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong updating a slider.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Slider not found or cannot be updated.' });
|
||||
}
|
||||
});
|
||||
|
||||
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,9 @@
|
||||
import 'express-session';
|
||||
|
||||
// Declaration merging to apply our own types to SessionData
|
||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId: number;
|
||||
}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import useDebouncedState from '@app/hooks/useDebouncedState';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type MultiRangeSliderProps = {
|
||||
min: number;
|
||||
max: number;
|
||||
defaultMinValue?: number;
|
||||
defaultMaxValue?: number;
|
||||
subText?: string;
|
||||
onUpdateMin: (min: number) => void;
|
||||
onUpdateMax: (max: number) => void;
|
||||
};
|
||||
|
||||
const MultiRangeSlider = ({
|
||||
min,
|
||||
max,
|
||||
defaultMinValue,
|
||||
defaultMaxValue,
|
||||
subText,
|
||||
onUpdateMin,
|
||||
onUpdateMax,
|
||||
}: MultiRangeSliderProps) => {
|
||||
const touched = useRef(false);
|
||||
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
|
||||
defaultMinValue ?? min
|
||||
);
|
||||
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
|
||||
defaultMaxValue ?? max
|
||||
);
|
||||
|
||||
const minThumb = ((valueMin - min) / (max - min)) * 100;
|
||||
const maxThumb = ((valueMax - min) / (max - min)) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMin(finalValueMin);
|
||||
}
|
||||
}, [finalValueMin, onUpdateMin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMax(finalValueMax);
|
||||
}
|
||||
}, [finalValueMax, onUpdateMax]);
|
||||
|
||||
useEffect(() => {
|
||||
touched.current = false;
|
||||
setValueMax(defaultMaxValue ?? max);
|
||||
setValueMin(defaultMinValue ?? min);
|
||||
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
|
||||
<Tooltip
|
||||
content={valueMin.toString()}
|
||||
tooltipConfig={{
|
||||
placement: 'top',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMin}
|
||||
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
|
||||
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
|
||||
}`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value <= valueMax) {
|
||||
touched.current = true;
|
||||
setValueMin(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={valueMax}>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMax}
|
||||
step="1"
|
||||
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value >= valueMin) {
|
||||
touched.current = true;
|
||||
setValueMax(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
|
||||
style={{
|
||||
left: `${minThumb}%`,
|
||||
right: `${100 - maxThumb}%`,
|
||||
}}
|
||||
/>
|
||||
{subText && (
|
||||
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
|
||||
<span>{subText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiRangeSlider;
|
@ -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,24 @@
|
||||
import { TagIcon } from '@heroicons/react/24/outline';
|
||||
import React from 'react';
|
||||
|
||||
type TagProps = {
|
||||
children: React.ReactNode;
|
||||
iconSvg?: JSX.Element;
|
||||
};
|
||||
|
||||
const Tag = ({ children, iconSvg }: TagProps) => {
|
||||
return (
|
||||
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
|
||||
{iconSvg ? (
|
||||
React.cloneElement(iconSvg, {
|
||||
className: 'mr-1 h-4 w-4',
|
||||
})
|
||||
) : (
|
||||
<TagIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tag;
|
@ -0,0 +1,28 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
|
||||
import type { ProductionCompany, TvNetwork } from '@server/models/common';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type CompanyTagProps = {
|
||||
type: 'studio' | 'network';
|
||||
companyId: number;
|
||||
};
|
||||
|
||||
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
|
||||
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
|
||||
`/api/v1/${type}/${companyId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<Tag>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
|
||||
};
|
||||
|
||||
export default CompanyTag;
|
@ -0,0 +1,506 @@
|
||||
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/useDiscover';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbGenre,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useEffect, 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',
|
||||
editSlider: 'Edit 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.',
|
||||
editsuccess: 'Edited slider and saved discover customization settings.',
|
||||
editfail: 'Failed to edit slider.',
|
||||
needresults: 'You need to have at least 1 result.',
|
||||
validationDatarequired: 'You must provide a data value.',
|
||||
validationTitlerequired: 'You must provide a title.',
|
||||
addcustomslider: 'Create Custom Slider',
|
||||
searchKeywords: 'Search keywords…',
|
||||
searchGenres: 'Search genres…',
|
||||
searchStudios: 'Search studios…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
});
|
||||
|
||||
type CreateSliderProps = {
|
||||
onCreate: () => void;
|
||||
slider?: Partial<DiscoverSlider>;
|
||||
};
|
||||
|
||||
type CreateOption = {
|
||||
type: DiscoverSliderType;
|
||||
title: string;
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const [resultCount, setResultCount] = useState(0);
|
||||
const [defaultDataValue, setDefaultDataValue] = useState<
|
||||
{ label: string; value: number }[] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (slider) {
|
||||
const loadDefaultKeywords = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keywords = await Promise.all(
|
||||
slider.data.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
})
|
||||
);
|
||||
|
||||
setDefaultDataValue(
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const loadDefaultGenre = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<TmdbGenre[]>(
|
||||
`/api/v1/genres/${
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
||||
}`
|
||||
);
|
||||
|
||||
const genre = response.data.find(
|
||||
(genre) => genre.id === Number(slider.data)
|
||||
);
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
label: genre?.name ?? '',
|
||||
value: genre?.id ?? 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const loadDefaultCompany = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<ProductionCompany>(
|
||||
`/api/v1/studio/${slider.data}`
|
||||
);
|
||||
|
||||
const studio = response.data;
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
label: studio.name ?? '',
|
||||
value: studio.id ?? 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
switch (slider.type) {
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
loadDefaultKeywords();
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
loadDefaultGenre();
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
loadDefaultCompany();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [slider]);
|
||||
|
||||
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 = 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,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadCompanyOptions = async (inputValue: string) => {
|
||||
if (inputValue === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
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={
|
||||
slider
|
||||
? {
|
||||
sliderType: slider.type,
|
||||
title: slider.title,
|
||||
data: slider.data,
|
||||
}
|
||||
: {
|
||||
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||
title: '',
|
||||
data: '',
|
||||
}
|
||||
}
|
||||
validationSchema={CreateSliderSchema}
|
||||
enableReinitialize
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
try {
|
||||
if (slider) {
|
||||
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
});
|
||||
} else {
|
||||
await axios.post('/api/v1/settings/discover/add', {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
});
|
||||
}
|
||||
|
||||
addToast(
|
||||
intl.formatMessage(
|
||||
slider ? messages.editsuccess : messages.addsuccess
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
onCreate();
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(slider ? messages.editfail : 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-${defaultDataValue}`}
|
||||
inputId="data"
|
||||
isMulti
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
noOptionsMessage={({ inputValue }) =>
|
||||
inputValue === ''
|
||||
? intl.formatMessage(messages.starttyping)
|
||||
: intl.formatMessage(messages.nooptions)
|
||||
}
|
||||
defaultValue={defaultDataValue}
|
||||
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-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadMovieGenreOptions}
|
||||
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`tv-genre-select-${defaultDataValue}}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadTvGenreOptions}
|
||||
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`studio-select-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadCompanyOptions}
|
||||
placeholder={intl.formatMessage(messages.searchStudios)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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 text-gray-100">
|
||||
<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(
|
||||
slider ? messages.editSlider : messages.addSlider
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeOption && values.title && values.data && (
|
||||
<div className="relative py-4">
|
||||
<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,147 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { MovieResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Movies',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortReleaseDateAsc: 'Release Date Ascending',
|
||||
sortReleaseDateDesc: 'Release Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
ReleaseDateAsc: 'release_date.asc',
|
||||
ReleaseDateDesc: 'release_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMovies = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult, unknown, FilterOptions>(
|
||||
'/api/v1/discover/movies',
|
||||
preparedFilters
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateDesc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="movie"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovies;
|
@ -0,0 +1,334 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import CompanyTag from '@app/components/CompanyTag';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import CreateSlider from '@app/components/Discover/CreateSlider';
|
||||
import GenreTag from '@app/components/GenreTag';
|
||||
import KeywordTag from '@app/components/KeywordTag';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
Bars3Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
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 DiscoverSliderEditProps = {
|
||||
slider: Partial<DiscoverSlider>;
|
||||
onEnable: () => void;
|
||||
onDelete: () => void;
|
||||
onPositionUpdate: (
|
||||
updatedItemId: number,
|
||||
position: keyof typeof Position,
|
||||
isClickable: boolean
|
||||
) => void;
|
||||
children: React.ReactNode;
|
||||
disableUpButton: boolean;
|
||||
disableDownButton: boolean;
|
||||
};
|
||||
|
||||
const DiscoverSliderEdit = ({
|
||||
slider,
|
||||
children,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onPositionUpdate,
|
||||
disableUpButton,
|
||||
disableDownButton,
|
||||
}: DiscoverSliderEditProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
|
||||
Position.None
|
||||
);
|
||||
|
||||
const { dragProps, isDragging } = useDrag({
|
||||
getItems() {
|
||||
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSlider = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/settings/discover/${slider.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, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
|
||||
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);
|
||||
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 'Unknown Slider';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`discover-slider-${slider.id}-editing`}
|
||||
data-testid="discover-slider-edit-mode"
|
||||
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
|
||||
isDragging ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
{...dragProps}
|
||||
{...dropProps}
|
||||
ref={ref}
|
||||
>
|
||||
{hoverPosition === Position.Above && (
|
||||
<div
|
||||
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
|
||||
/>
|
||||
)}
|
||||
{hoverPosition === Position.Below && (
|
||||
<div
|
||||
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
|
||||
<div
|
||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
<div>{getSliderTitle(slider)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
slider.data ? 'mb-4' : ''
|
||||
} flex-1 md:mb-0`}
|
||||
>
|
||||
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
|
||||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
|
||||
<div className="flex space-x-2">
|
||||
{slider.data?.split(',').map((keywordId) => (
|
||||
<KeywordTag
|
||||
key={`slider-keywords-${slider.id}-${keywordId}`}
|
||||
keywordId={Number(keywordId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
|
||||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
|
||||
<CompanyTag
|
||||
type={
|
||||
slider.type === DiscoverSliderType.TMDB_STUDIO
|
||||
? 'studio'
|
||||
: 'network'
|
||||
}
|
||||
companyId={Number(slider.data)}
|
||||
/>
|
||||
)}
|
||||
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
|
||||
<GenreTag
|
||||
type={
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
|
||||
? 'movie'
|
||||
: 'tv'
|
||||
}
|
||||
genreId={Number(slider.data)}
|
||||
/>
|
||||
)}
|
||||
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
|
||||
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!slider.isBuiltIn && (
|
||||
<>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
buttonType="warning"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
buttonType="default"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="discover-slider-remove-button"
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
deleteSlider();
|
||||
}}
|
||||
>
|
||||
<XMarkIcon />
|
||||
<span>{intl.formatMessage(messages.remove)}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
|
||||
<button
|
||||
className={'hover:text-white disabled:text-gray-800'}
|
||||
onClick={() =>
|
||||
onPositionUpdate(Number(slider.id), Position.Above, true)
|
||||
}
|
||||
disabled={disableUpButton}
|
||||
>
|
||||
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||
</button>
|
||||
<button
|
||||
className={'hover:text-white disabled:text-gray-800'}
|
||||
onClick={() =>
|
||||
onPositionUpdate(Number(slider.id), Position.Below, true)
|
||||
}
|
||||
disabled={disableDownButton}
|
||||
>
|
||||
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
|
||||
<Tooltip content={intl.formatMessage(messages.enable)}>
|
||||
<div>
|
||||
<SlideCheckbox
|
||||
onClick={() => {
|
||||
onEnable();
|
||||
}}
|
||||
checked={slider.enabled}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="p-4">
|
||||
<CreateSlider
|
||||
onCreate={() => {
|
||||
onDelete();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
slider={slider}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverSliderEdit;
|
@ -0,0 +1,145 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Series',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortFirstAirDateAsc: 'First Air Date Ascending',
|
||||
sortFirstAirDateDesc: 'First Air Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
FirstAirDateAsc: 'first_air_date.asc',
|
||||
FirstAirDateDesc: 'first_air_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverTv = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
|
||||
...preparedFilters,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.FirstAirDateDesc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.FirstAirDateAsc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="tv"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTv;
|
@ -0,0 +1,297 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import {
|
||||
useBatchUpdateQueryParams,
|
||||
useUpdateQueryParams,
|
||||
} from '@app/hooks/useUpdateQueryParams';
|
||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||
|
||||
const messages = defineMessages({
|
||||
filters: 'Filters',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
releaseDate: 'Release Date',
|
||||
firstAirDate: 'First Air Date',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
studio: 'Studio',
|
||||
genres: 'Genres',
|
||||
keywords: 'Keywords',
|
||||
originalLanguage: 'Original Language',
|
||||
runtimeText: '{minValue}-{maxValue} minute runtime',
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
type: 'movie' | 'tv';
|
||||
currentFilters: FilterOptions;
|
||||
};
|
||||
|
||||
const FilterSlideover = ({
|
||||
show,
|
||||
onClose,
|
||||
type,
|
||||
currentFilters,
|
||||
}: FilterSlideoverProps) => {
|
||||
const intl = useIntl();
|
||||
const { currentSettings } = useSettings();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||
|
||||
const dateGte =
|
||||
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||
const dateLte =
|
||||
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
show={show}
|
||||
title={intl.formatMessage(messages.filters)}
|
||||
subText={intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(currentFilters),
|
||||
})}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-semibold">
|
||||
{intl.formatMessage(
|
||||
type === 'movie' ? messages.releaseDate : messages.firstAirDate
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-40 flex space-x-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateGte] ?? null,
|
||||
endDate: currentFilters[dateGte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateGte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="fromdate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateLte] ?? null,
|
||||
endDate: currentFilters[dateLte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateLte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="todate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'movie' && (
|
||||
<>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.studio)}
|
||||
</span>
|
||||
<CompanySelector
|
||||
defaultValue={currentFilters.studio}
|
||||
onChange={(value) => {
|
||||
updateQueryParams('studio', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</span>
|
||||
<GenreSelector
|
||||
type={type}
|
||||
defaultValue={currentFilters.genre}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<KeywordSelector
|
||||
defaultValue={currentFilters.keywords}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.originalLanguage)}
|
||||
</span>
|
||||
<LanguageSelector
|
||||
value={currentFilters.language}
|
||||
serverValue={currentSettings.originalLanguage}
|
||||
isUserSettings
|
||||
setFieldValue={(_key, value) => {
|
||||
updateQueryParams('language', value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.runtime)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={0}
|
||||
max={400}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeGte',
|
||||
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeLte',
|
||||
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
defaultMaxValue={
|
||||
currentFilters.withRuntimeLte
|
||||
? Number(currentFilters.withRuntimeLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.withRuntimeGte
|
||||
? Number(currentFilters.withRuntimeGte)
|
||||
: undefined
|
||||
}
|
||||
subText={intl.formatMessage(messages.runtimeText, {
|
||||
minValue: currentFilters.withRuntimeGte ?? 0,
|
||||
maxValue: currentFilters.withRuntimeLte ?? 400,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.tmdbuserscore)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={1}
|
||||
max={10}
|
||||
defaultMaxValue={
|
||||
currentFilters.voteAverageLte
|
||||
? Number(currentFilters.voteAverageLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.voteAverageGte
|
||||
? Number(currentFilters.voteAverageGte)
|
||||
: undefined
|
||||
}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'voteAverageGte',
|
||||
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'voteAverageLte',
|
||||
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
subText={intl.formatMessage(messages.ratingText, {
|
||||
minValue: currentFilters.voteAverageGte ?? 1,
|
||||
maxValue: currentFilters.voteAverageLte ?? 10,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
<WatchProviderSelector
|
||||
type={type}
|
||||
region={currentFilters.watchRegion}
|
||||
activeProviders={
|
||||
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
|
||||
[]
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
if (providers.length) {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: region,
|
||||
watchProviders: providers.join('|'),
|
||||
});
|
||||
} else {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: undefined,
|
||||
watchProviders: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={Object.keys(currentFilters).length === 0}
|
||||
onClick={() => {
|
||||
const copyCurrent = Object.assign({}, currentFilters);
|
||||
(
|
||||
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
|
||||
).forEach((k) => {
|
||||
copyCurrent[k] = undefined;
|
||||
});
|
||||
batchUpdateQueryParams(copyCurrent);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<span>{intl.formatMessage(messages.clearfilters)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterSlideover;
|
@ -0,0 +1,79 @@
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/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?.isPlexUser ? '/api/v1/discover/watchlist' : null, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!user?.isPlexUser ||
|
||||
(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>
|
||||
<ArrowRightCircleIcon />
|
||||
</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 { ArrowRightCircleIcon } from '@heroicons/react/24/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>
|
||||
<ArrowRightCircleIcon />
|
||||
</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,28 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import { RectangleStackIcon } from '@heroicons/react/24/outline';
|
||||
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type GenreTagProps = {
|
||||
type: 'tv' | 'movie';
|
||||
genreId: number;
|
||||
};
|
||||
|
||||
const GenreTag = ({ genreId, type }: GenreTagProps) => {
|
||||
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<Tag>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const genre = data?.find((genre) => genre.id === genreId);
|
||||
|
||||
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
|
||||
};
|
||||
|
||||
export default GenreTag;
|
@ -0,0 +1,24 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type KeywordTagProps = {
|
||||
keywordId: number;
|
||||
};
|
||||
|
||||
const KeywordTag = ({ keywordId }: KeywordTagProps) => {
|
||||
const { data, error } = useSWR<Keyword>(`/api/v1/keyword/${keywordId}`);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<Tag>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return <Tag>{data?.name}</Tag>;
|
||||
};
|
||||
|
||||
export default KeywordTag;
|
@ -0,0 +1,212 @@
|
||||
import { menuMessages } from '@app/components/Layout/Sidebar';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FilmIcon,
|
||||
SparklesIcon,
|
||||
TvIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ClockIcon as FilledClockIcon,
|
||||
CogIcon as FilledCogIcon,
|
||||
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||
FilmIcon as FilledFilmIcon,
|
||||
SparklesIcon as FilledSparklesIcon,
|
||||
TvIcon as FilledTvIcon,
|
||||
UsersIcon as FilledUsersIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { cloneElement, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
interface MenuLink {
|
||||
href: string;
|
||||
svgIcon: JSX.Element;
|
||||
svgIconSelected: JSX.Element;
|
||||
content: React.ReactNode;
|
||||
activeRegExp: RegExp;
|
||||
as?: string;
|
||||
requiredPermission?: Permission | Permission[];
|
||||
permissionType?: 'and' | 'or';
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
const MobileMenu = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
useClickOutside(ref, () => {
|
||||
setTimeout(() => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
const toggle = () => setIsOpen(!isOpen);
|
||||
|
||||
const menuLinks: MenuLink[] = [
|
||||
{
|
||||
href: '/',
|
||||
content: intl.formatMessage(menuMessages.dashboard),
|
||||
svgIcon: <SparklesIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledSparklesIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/(discover\/?)?$/,
|
||||
},
|
||||
{
|
||||
href: '/discover/movies',
|
||||
content: intl.formatMessage(menuMessages.browsemovies),
|
||||
svgIcon: <FilmIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledFilmIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/discover\/movies$/,
|
||||
},
|
||||
{
|
||||
href: '/discover/tv',
|
||||
content: intl.formatMessage(menuMessages.browsetv),
|
||||
svgIcon: <TvIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/discover\/tv$/,
|
||||
},
|
||||
{
|
||||
href: '/requests',
|
||||
content: intl.formatMessage(menuMessages.requests),
|
||||
svgIcon: <ClockIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/requests/,
|
||||
},
|
||||
{
|
||||
href: '/issues',
|
||||
content: intl.formatMessage(menuMessages.issues),
|
||||
svgIcon: <ExclamationTriangleIcon className="h-6 w-6" />,
|
||||
svgIconSelected: <FilledExclamationTriangleIcon className="h-6 w-6" />,
|
||||
activeRegExp: /^\/issues/,
|
||||
requiredPermission: [
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
],
|
||||
permissionType: 'or',
|
||||
},
|
||||
{
|
||||
href: '/users',
|
||||
content: intl.formatMessage(menuMessages.users),
|
||||
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
|
||||
svgIconSelected: <FilledUsersIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/users/,
|
||||
requiredPermission: Permission.MANAGE_USERS,
|
||||
dataTestId: 'sidebar-menu-users',
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
content: intl.formatMessage(menuMessages.settings),
|
||||
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
|
||||
svgIconSelected: <FilledCogIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/settings/,
|
||||
requiredPermission: Permission.ADMIN,
|
||||
dataTestId: 'sidebar-menu-settings',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredLinks = menuLinks.filter(
|
||||
(link) =>
|
||||
!link.requiredPermission ||
|
||||
hasPermission(link.requiredPermission, {
|
||||
type: link.permissionType ?? 'and',
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as="div"
|
||||
ref={ref}
|
||||
enter="transition transform duration-500"
|
||||
enterFrom="opacity-0 translate-y-0"
|
||||
enterTo="opacity-100 -translate-y-full"
|
||||
leave="transition duration-500 transform"
|
||||
leaveFrom="opacity-100 -translate-y-full"
|
||||
leaveTo="opacity-0 translate-y-0"
|
||||
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||
>
|
||||
{filteredLinks.map((link) => {
|
||||
const isActive = router.pathname.match(link.activeRegExp);
|
||||
return (
|
||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||
<a
|
||||
className={`flex items-center space-x-2 ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsOpen(false)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||
className: 'h-5 w-5',
|
||||
})}
|
||||
<span>{link.content}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Transition>
|
||||
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
||||
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
||||
{filteredLinks
|
||||
.slice(0, filteredLinks.length === 5 ? 5 : 4)
|
||||
.map((link) => {
|
||||
const isActive =
|
||||
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||
return (
|
||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||
<a
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
>
|
||||
{cloneElement(
|
||||
isActive ? link.svgIconSelected : link.svgIcon,
|
||||
{
|
||||
className: 'h-6 w-6',
|
||||
}
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{filteredLinks.length > 4 && filteredLinks.length !== 5 && (
|
||||
<button
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
isOpen ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
onClick={() => toggle()}
|
||||
>
|
||||
{isOpen ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<EllipsisHorizontalIcon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMenu;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue