diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index ec92a9025..3e74032cc 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -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( ) ); + +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; diff --git a/src/components/layout/header/Search.tsx b/src/components/layout/header/Search.tsx index 10d4cb6a7..5b0b011e8 100644 --- a/src/components/layout/header/Search.tsx +++ b/src/components/layout/header/Search.tsx @@ -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, diff --git a/src/components/layout/new-header/MovieModal.tsx b/src/components/layout/new-header/MovieModal.tsx new file mode 100644 index 000000000..36c6aead9 --- /dev/null +++ b/src/components/layout/new-header/MovieModal.tsx @@ -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 & { + 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 ( +
+ +
+ ); + + return ( + + + Found {overseerrResults?.length} results for {search} + + + {overseerrResults?.map((result, index: number) => ( + + + {index < overseerrResults.length - 1 && } + + ))} + + + ); +}; + +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 ( + + {`${integration.label} + {integration.label} movies + + } + > + + + ); +}; + +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 ( + + + + + + {movie.title ?? movie.name ?? movie.originalName} + + + {movie.overview} + + + + {!movie.mediaInfo?.mediaAddedAt && ( + <> + + + + )} + {mediaUrl && ( + + )} + {serviceUrl && ( + + )} + + + + ); +}; diff --git a/src/components/layout/new-header/search.tsx b/src/components/layout/new-header/search.tsx index 0be7f218c..ab3a0c748 100644 --- a/src/components/layout/new-header/search.tsx +++ b/src/components/layout/new-header/search.tsx @@ -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(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,47 +36,61 @@ export const Search = () => { const data = [...engines, ...apps]; return ( - 768} - rightSection={ - ref.current?.focus()} - color={colors.gray[5]} - size={16} - stroke={1.5} - /> - } - limit={8} - value={search} - onChange={setSearch} - data={data} - itemComponent={SearchItemComponent} - filter={(value, item: SearchAutoCompleteItem) => - engines.some((engine) => engine.sort === item.sort) || - item.value.toLowerCase().includes(value.trim().toLowerCase()) - } - classNames={{ - input: 'dashboard-header-search-input', - root: 'dashboard-header-search-root', - }} - onItemSubmit={(item: SearchAutoCompleteItem) => { - setSearch(''); - if (item.sort === 'movie') { - // TODO: show movie modal - console.log('movie'); - return; + <> + 768} + rightSection={ + ref.current?.focus()} + color={colors.gray[5]} + size={16} + stroke={1.5} + /> } - const target = userWithSettings?.settings.openSearchInNewTab ? '_blank' : '_self'; - window.open(item.metaData.url, target); - }} - aria-label="Search" - /> + limit={8} + value={search} + onChange={setSearch} + data={data} + itemComponent={SearchItemComponent} + filter={(value, item: SearchAutoCompleteItem) => + engines.some((engine) => engine.sort === item.sort) || + item.value.toLowerCase().includes(value.trim().toLowerCase()) + } + classNames={{ + input: 'dashboard-header-search-input', + root: 'dashboard-header-search-root', + }} + onItemSubmit={(item: SearchAutoCompleteItem) => { + setSearch(''); + if (item.sort === 'movie') { + // TODO: show movie modal + 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'; + window.open(item.metaData.url, target); + }} + aria-label="Search" + /> + { + movieModal.close(); + router.push(router.pathname, undefined, { shallow: true }); + }} + /> + ); }; diff --git a/src/modules/overseerr/RequestModal.tsx b/src/modules/overseerr/RequestModal.tsx index 523263cd8..eeae9a931 100644 --- a/src/modules/overseerr/RequestModal.tsx +++ b/src/modules/overseerr/RequestModal.tsx @@ -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={ diff --git a/src/modules/overseerr/SearchResult.d.ts b/src/modules/overseerr/SearchResult.ts similarity index 100% rename from src/modules/overseerr/SearchResult.d.ts rename to src/modules/overseerr/SearchResult.ts diff --git a/src/server/api/routers/overseerr.ts b/src/server/api/routers/overseerr.ts index 0e3545425..222082603 100644 --- a/src/server/api/routers/overseerr.ts +++ b/src/server/api/routers/overseerr.ts @@ -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); }),