Implement new movie search modals

This commit is contained in:
Meier Lukas
2023-07-31 22:30:48 +02:00
parent 630548e022
commit 386fddc050
7 changed files with 357 additions and 129 deletions

View File

@@ -20,83 +20,9 @@ interface IntegrationSelectorProps {
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
const data: SelectItem[] = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
label: 'SABnzbd',
},
{
value: 'nzbGet',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
label: 'NZBGet',
},
{
value: 'deluge',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
label: 'Deluge',
},
{
value: 'transmission',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
label: 'Transmission',
},
{
value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
label: 'qBittorrent',
},
{
value: 'jellyseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
label: 'Jellyseerr',
},
{
value: 'overseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
label: 'Overseerr',
},
{
value: 'sonarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
label: 'Sonarr',
},
{
value: 'radarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
label: 'Radarr',
},
{
value: 'lidarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
label: 'Lidarr',
},
{
value: 'readarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
label: 'Readarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
label: 'Jellyfin',
},
{
value: 'plex',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
label: 'Plex',
},
{
value: 'pihole',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
label: 'PiHole',
},
{
value: 'adGuardHome',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
label: 'AdGuard Home',
},
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const data = availableIntegrations.filter((x) =>
Object.keys(integrationFieldProperties).includes(x.value)
);
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
@@ -181,3 +107,81 @@ const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
</div>
)
);
export const availableIntegrations = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
label: 'SABnzbd',
},
{
value: 'nzbGet',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
label: 'NZBGet',
},
{
value: 'deluge',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
label: 'Deluge',
},
{
value: 'transmission',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
label: 'Transmission',
},
{
value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
label: 'qBittorrent',
},
{
value: 'jellyseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
label: 'Jellyseerr',
},
{
value: 'overseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
label: 'Overseerr',
},
{
value: 'sonarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
label: 'Sonarr',
},
{
value: 'radarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
label: 'Radarr',
},
{
value: 'lidarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
label: 'Lidarr',
},
{
value: 'readarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
label: 'Readarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
label: 'Jellyfin',
},
{
value: 'plex',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
label: 'Plex',
},
{
value: 'pihole',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
label: 'PiHole',
},
{
value: 'adGuardHome',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
label: 'AdGuard Home',
},
] as const satisfies Readonly<SelectItem[]>;

View File

@@ -301,12 +301,13 @@ const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self';
};
const useOverseerrSearchQuery = (query: string, isEnabled: boolean) => {
export const useOverseerrSearchQuery = (query: string, isEnabled: boolean) => {
const { name: configName } = useConfigContext();
return api.overseerr.all.useQuery(
return api.overseerr.search.useQuery(
{
query,
configName: configName!,
integration: 'overseerr',
},
{
enabled: isEnabled,

View File

@@ -0,0 +1,204 @@
import {
Button,
Center,
Divider,
Group,
Loader,
Image as MantineImage,
Modal,
ScrollArea,
Stack,
Text,
Title,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import Image from 'next/image';
import { useRouter } from 'next/router';
import React, { useMemo } from 'react';
import { z } from 'zod';
import { availableIntegrations } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector';
import { useConfigContext } from '~/config/provider';
import { RequestModal } from '~/modules/overseerr/RequestModal';
import { RouterOutputs, api } from '~/utils/api';
type MovieModalProps = {
opened: boolean;
closeModal: () => void;
};
const queryParamsSchema = z.object({
movie: z.literal('true'),
search: z.string().nonempty(),
type: z.enum(['jellyseerr', 'overseerr']),
});
type MovieResultsProps = z.infer<typeof queryParamsSchema> & {
closeModal: () => void;
};
const MovieResults = ({ search, type, closeModal }: MovieResultsProps) => {
const { name: configName } = useConfigContext();
const { data: overseerrResults, isLoading } = api.overseerr.search.useQuery(
{
query: search,
configName: configName!,
integration: type,
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
}
);
if (isLoading)
return (
<Center>
<Loader />
</Center>
);
return (
<Stack>
<Text>
Found {overseerrResults?.length} results for <b>{search}</b>
</Text>
<Stack spacing="xs">
{overseerrResults?.map((result, index: number) => (
<React.Fragment key={index}>
<MovieDisplay movie={result} type={type} closeModal={closeModal} />
{index < overseerrResults.length - 1 && <Divider variant="dashed" my="xs" />}
</React.Fragment>
))}
</Stack>
</Stack>
);
};
export const MovieModal = ({ opened, closeModal }: MovieModalProps) => {
const query = useRouter().query;
const queryParams = queryParamsSchema.safeParse(query);
if (!queryParams.success) {
return null;
}
const integration = useMemo(() => {
return availableIntegrations.find((x) => x.value === queryParams.data.type)!;
}, [queryParams.data.type]);
return (
<Modal
opened={opened}
onClose={closeModal}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
title={
<Group>
<Image src={integration.image} width={30} height={30} alt={`${integration.label} icon`} />
<Title order={4}>{integration.label} movies</Title>
</Group>
}
>
<MovieResults
movie={queryParams.data.movie}
search={queryParams.data.search}
type={queryParams.data.type}
closeModal={closeModal}
/>
</Modal>
);
};
type MovieDisplayProps = {
movie: RouterOutputs['overseerr']['search'][number];
type: 'jellyseerr' | 'overseerr';
closeModal: () => void;
};
const MovieDisplay = ({ movie, type, closeModal }: MovieDisplayProps) => {
const { t } = useTranslation('modules/common-media-cards');
const { config } = useConfigContext();
const [requestModalOpened, requestModal] = useDisclosure(false);
if (!config) {
return null;
}
const service = config.apps.find((service) => service.integration.type === type);
const mediaUrl = movie.mediaInfo?.plexUrl ?? movie.mediaInfo?.mediaUrl;
const serviceUrl = service?.behaviour.externalUrl ? service.behaviour.externalUrl : service?.url;
return (
<Group noWrap style={{ maxHeight: 250 }} p={0} m={0} spacing="xs" align="stretch">
<MantineImage
withPlaceholder
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2/${
movie.posterPath ?? movie.backdropPath
}`}
height={200}
width={150}
radius="md"
fit="cover"
/>
<Stack justify="space-between">
<Stack spacing={4}>
<Title lineClamp={2} order={5}>
{movie.title ?? movie.name ?? movie.originalName}
</Title>
<Text color="dimmed" size="xs" lineClamp={4}>
{movie.overview}
</Text>
</Stack>
<Group spacing="xs">
{!movie.mediaInfo?.mediaAddedAt && (
<>
<RequestModal
base={movie}
opened={requestModalOpened}
setOpened={requestModal.toggle}
/>
<Button
onClick={() => {
requestModal.open();
}}
variant="light"
size="sm"
rightIcon={<IconDownload size={15} />}
>
{t('buttons.request')}
</Button>
</>
)}
{mediaUrl && (
<Button
component="a"
target="_blank"
variant="outline"
href={mediaUrl}
size="sm"
rightIcon={<IconPlayerPlay size={15} />}
>
{t('buttons.play')}
</Button>
)}
{serviceUrl && (
<Button
component="a"
target="_blank"
href={serviceUrl}
variant="outline"
size="sm"
rightIcon={<IconExternalLink size={15} />}
>
TMDb
</Button>
)}
</Group>
</Stack>
</Group>
);
};

View File

@@ -1,5 +1,5 @@
import { Autocomplete, Group, Kbd, Text, Tooltip, useMantineTheme } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { Autocomplete, Group, Kbd, Modal, Text, Tooltip, useMantineTheme } from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import {
IconBrandYoutube,
IconDownload,
@@ -8,10 +8,13 @@ import {
IconWorld,
TablerIconsProps,
} from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { ReactNode, forwardRef, useMemo, useRef, useState } from 'react';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { MovieModal } from './MovieModal';
export const Search = () => {
const [search, setSearch] = useState('');
const ref = useRef<HTMLInputElement>(null);
@@ -19,6 +22,8 @@ export const Search = () => {
const { data: userWithSettings } = api.user.getWithSettings.useQuery();
const { config } = useConfigContext();
const { colors } = useMantineTheme();
const router = useRouter();
const [showMovieModal, movieModal] = useDisclosure(router.query.movie === 'true');
const apps = useConfigApps(search);
const engines = generateEngines(
@@ -31,6 +36,7 @@ export const Search = () => {
const data = [...engines, ...apps];
return (
<>
<Autocomplete
ref={ref}
radius="xl"
@@ -64,7 +70,12 @@ export const Search = () => {
setSearch('');
if (item.sort === 'movie') {
// TODO: show movie modal
console.log('movie');
const url = new URL(`${window.location.origin}${router.asPath}`);
url.searchParams.set('movie', 'true');
url.searchParams.set('search', search);
url.searchParams.set('type', item.value);
router.push(url, undefined, { shallow: true });
movieModal.open();
return;
}
const target = userWithSettings?.settings.openSearchInNewTab ? '_blank' : '_self';
@@ -72,6 +83,14 @@ export const Search = () => {
}}
aria-label="Search"
/>
<MovieModal
opened={showMovieModal}
closeModal={() => {
movieModal.close();
router.push(router.pathname, undefined, { shallow: true });
}}
/>
</>
);
};

View File

@@ -9,7 +9,7 @@ import { api } from '~/utils/api';
import { useColorTheme } from '../../tools/color';
import { MovieResult } from './Movie.d';
import { Result } from './SearchResult.d';
import { Result } from './SearchResult';
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
interface RequestModalProps {
@@ -70,7 +70,7 @@ export function MovieRequestModal({
radius="lg"
size="lg"
trapFocus
zIndex={150}
zIndex={250}
withinPortal
opened={opened}
title={
@@ -155,6 +155,7 @@ export function TvRequestModal({
onClose={() => setOpened(false)}
radius="lg"
size="lg"
zIndex={250}
opened={opened}
title={
<Group>

View File

@@ -3,17 +3,18 @@ import axios from 'axios';
import Consola from 'consola';
import { z } from 'zod';
import { MovieResult } from '~/modules/overseerr/Movie';
import { Result } from '~/modules/overseerr/SearchResult';
import { OriginalLanguage, Result } from '~/modules/overseerr/SearchResult';
import { TvShowResult } from '~/modules/overseerr/TvShow';
import { getConfig } from '~/tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const overseerrRouter = createTRPCRouter({
all: publicProcedure
search: publicProcedure
.input(
z.object({
configName: z.string(),
integration: z.enum(['overseerr', 'jellyseerr']),
query: z.string().or(z.undefined()),
limit: z.number().default(10),
})
@@ -21,9 +22,7 @@ export const overseerrRouter = createTRPCRouter({
.query(async ({ input }) => {
const config = getConfig(input.configName);
const app = config.apps.find(
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
);
const app = config.apps.find((app) => app.integration?.type === input.integration);
if (input.query === '' || input.query === undefined) {
return [];
@@ -44,7 +43,7 @@ export const overseerrRouter = createTRPCRouter({
'X-Api-Key': apiKey,
},
})
.then((res) => res.data as Result[]);
.then((res) => res.data.results as Result[]);
return data.slice(0, input.limit);
}),