diff --git a/package.json b/package.json index 47e1b68cb..b81ca8858 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "@mantine/next": "^5.2.3", "@mantine/notifications": "^5.7.2", "@mantine/prism": "^5.0.0", - "@mantine/spotlight": "^5.8.2", "@nivo/core": "^0.79.0", "@nivo/line": "^0.79.1", "@tabler/icons": "^1.78.0", diff --git a/public/locales/en/modules/search.json b/public/locales/en/modules/search.json index 38d1aa1a2..cf3f50de0 100644 --- a/public/locales/en/modules/search.json +++ b/public/locales/en/modules/search.json @@ -5,5 +5,26 @@ }, "input": { "placeholder": "Search the web..." - } + }, + "switched-to": "Switched to", + "searchEngines": { + "search": { + "name": "Web", + "description": "Search using your search engine (defined in settings)" + }, + "youtube": { + "name": "Youtube", + "description": "Search on Youtube" + }, + "torrents": { + "name": "Torrents", + "description": "Search for Torrents" + }, + "overseerr": { + "name": "Overseerr", + "description": "Search for Movies and TV Shows using Overseerr (module must be enabled)" + } + }, + "tip": "You can select the search bar with the shortcut ", + "switchedSearchEngine": "Switched to searching with {{searchEngine}}" } \ No newline at end of file diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 445cf80aa..803ea7690 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -1,9 +1,4 @@ -import { - Group, - Header as Head, - useMantineColorScheme, - useMantineTheme, -} from '@mantine/core'; +import { Group, Header as Head, useMantineColorScheme, useMantineTheme } from '@mantine/core'; import { useViewportSize } from '@mantine/hooks'; import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem'; diff --git a/src/modules/index.ts b/src/modules/index.ts index c4e86eadf..f3b7292a7 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,4 +8,3 @@ export * from './weather'; export * from './docker'; export * from './overseerr'; export * from './usenet'; -export * from './spotlight'; diff --git a/src/modules/search/SearchModule.tsx b/src/modules/search/SearchModule.tsx index 6540178c3..2974e3c29 100644 --- a/src/modules/search/SearchModule.tsx +++ b/src/modules/search/SearchModule.tsx @@ -1,236 +1,159 @@ -import { Kbd, createStyles, Autocomplete, Popover, ScrollArea, Divider } from '@mantine/core'; -import { useClickOutside, useDebouncedValue, useHotkeys } from '@mantine/hooks'; -import { useForm } from '@mantine/form'; -import React, { forwardRef, useEffect, useRef, useState } from 'react'; import { - IconSearch as Search, - IconBrandYoutube as BrandYoutube, - IconDownload as Download, - IconMovie, -} from '@tabler/icons'; + ActionIcon, + Box, + createStyles, + Divider, + Kbd, + Menu, + Popover, + ScrollArea, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconSearch, IconBrandYoutube, IconDownload, IconMovie } from '@tabler/icons'; +import React, { useEffect, useRef, useState } from 'react'; +import { useDebouncedValue, useHotkeys } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; import { useTranslation } from 'next-i18next'; import axios from 'axios'; -import { showNotification } from '@mantine/notifications'; -import { useConfig } from '../../tools/state'; import { IModule } from '../ModuleTypes'; +import { useConfig } from '../../tools/state'; import { OverseerrModule } from '../overseerr'; +import Tip from '../../components/layout/Tip'; import { OverseerrMediaDisplay } from '../common'; -import SmallServiceItem from '../../components/AppShelf/SmallServiceItem'; - -const useStyles = createStyles((theme) => ({ - hide: { - [theme.fn.smallerThan('sm')]: { - display: 'none', - }, - display: 'flex', - alignItems: 'center', - }, -})); export const SearchModule: IModule = { title: 'Search', - icon: Search, - component: SearchBar, + icon: IconSearch, + component: SearchModuleComponent, id: 'search', }; -export default function SearchBar(props: any) { - const { classes, cx } = useStyles(); - // Config +interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { + label: string; + disabled: boolean; + value: string; + description: string; + icon: React.ReactNode; + url: string; + shortcut: string; +} + +const useStyles = createStyles((theme) => ({ + item: { + '&[data-hovered]': { + backgroundColor: theme.colors[theme.primaryColor][theme.fn.primaryShade()], + color: theme.white, + }, + }, +})); + +export function SearchModuleComponent(props: any) { const { config } = useConfig(); - const isModuleEnabled = config.modules?.[SearchModule.id]?.enabled ?? false; + const { t } = useTranslation('modules/search'); + const [searchQuery, setSearchQuery] = useState(''); + const [debounced, cancel] = useDebouncedValue(searchQuery, 250); + const queryUrl = config.settings.searchUrl; const isOverseerrEnabled = config.modules?.[OverseerrModule.id]?.enabled ?? false; const OverseerrService = config.services.find( (service) => service.type === 'Overseerr' || service.type === 'Jellyseerr' ); - const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q='; - const [OverseerrResults, setOverseerrResults] = useState([]); - const [loading, setLoading] = useState(false); - const [icon, setIcon] = useState(); - const [results, setResults] = useState([]); - const [opened, setOpened] = useState(false); - const ref = useClickOutside(() => setOpened(false)); - - const textInput = useRef(); - useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]); - - const form = useForm({ - initialValues: { - query: '', + const searchEnginesList: ItemProps[] = [ + { + icon: , + disabled: false, + label: t('searchEngines.search.name'), + value: 'search', + description: t('searchEngines.search.description'), + url: queryUrl, + shortcut: 's', }, - }); - const [debounced, cancel] = useDebouncedValue(form.values.query, 250); - const { t } = useTranslation('modules/search'); + { + icon: , + disabled: false, + label: t('searchEngines.torrents.name'), + value: 'torrents', + description: t('searchEngines.torrents.description'), + url: 'https://www.torrentdownloads.me/search/?search=', + shortcut: 't', + }, + { + icon: , + disabled: false, + label: t('searchEngines.youtube.name'), + value: 'youtube', + description: t('searchEngines.youtube.description'), + url: 'https://www.youtube.com/results?search_query=', + shortcut: 'y', + }, + { + icon: , + disabled: !(isOverseerrEnabled === true && OverseerrService !== undefined), + label: t('searchEngines.overseerr.name'), + value: 'overseerr', + description: t('searchEngines.overseerr.description'), + url: `${OverseerrService?.url}search?query=`, + shortcut: 'm', + }, + ]; + const [selectedSearchEngine, setSearchEngine] = useState(searchEnginesList[0]); + const textInput = useRef(null); + useHotkeys([['mod+K', () => textInput.current && textInput.current.focus()]]); + const { classes } = useStyles(); + const openInNewTab = config.settings.searchNewTab ? '_blank' : '_self'; + const [OverseerrResults, setOverseerrResults] = useState([]); + const [opened, setOpened] = useState(false); useEffect(() => { - if (OverseerrService === undefined && isOverseerrEnabled) { - showNotification({ - title: 'Overseerr integration', - message: - 'Module enabled but no service is configured with the type "Overseerr" / "Jellyseerr"', - color: 'red', + if (debounced !== '' && selectedSearchEngine.value === 'overseerr' && searchQuery.length > 3) { + axios.get(`/api/modules/overseerr?query=${searchQuery}`).then((res) => { + setOverseerrResults(res.data.results ?? []); }); } - }, [OverseerrService, isOverseerrEnabled]); - - useEffect(() => { - if ( - form.values.query !== debounced || - form.values.query === '' || - (form.values.query.startsWith('!') && !form.values.query.startsWith('!os')) - ) { - return; - } - if (form.values.query.startsWith('!os')) { - axios - .get(`/api/modules/overseerr?query=${form.values.query.replace('!os', '').trim()}`) - .then((res) => { - setOverseerrResults(res.data.results ?? []); - setLoading(false); - }); - setLoading(true); - } else { - setOverseerrResults([]); - axios - .get(`/api/modules/search?q=${form.values.query}`) - .then((res) => setResults(res.data ?? [])); - } }, [debounced]); + const isModuleEnabled = config.modules?.[SearchModule.id]?.enabled ?? false; if (!isModuleEnabled) { return null; } - // Match all the services that contain the query in their name if the query is not empty - const matchingServices = config.services.filter((service) => { - if (form.values.query === '' || form.values.query === undefined) { - return false; - } - return service.name.toLowerCase().includes(form.values.query.toLowerCase()); - }); - const autocompleteData = matchingServices.map((service) => ({ - label: service.name, - value: service.name, - icon: service.icon, - url: service.openedUrl ?? service.url, - })); - // Append the matching results to the autocomplete data - const autoCompleteResults = results.map((result) => ({ - label: result.phrase, - value: result.phrase, - icon: result.icon, - url: result.url, - })); - autocompleteData.push(...autoCompleteResults); - - const AutoCompleteItem = forwardRef( - ({ label, value, icon, url, ...others }: any, ref) => ( -
- -
- ) - ); - + //TODO: Fix the bug where clicking anything inside the Modal to ask for a movie + // will close it (Because it closes the underlying Popover) return ( -
{ - // If query contains !yt or !t add "Searching on YouTube" or "Searching torrent" - const query = form.values.query.trim(); - switch (query.substring(0, 3)) { - case '!yt': - setIcon(); - break; - case '!t ': - setIcon(); - break; - case '!os': - setIcon(); - break; - default: - setIcon(); - break; - } - }} - onSubmit={form.onSubmit((values) => { - const query = values.query.trim(); - const open_in = config.settings.searchNewTab ? '_blank' : '_self'; - setTimeout(() => { - form.setValues({ query: '' }); - switch (query.substring(0, 3)) { - case '!yt': - window.open( - `https://www.youtube.com/results?search_query=${query.substring(3)}`, - open_in - ); - break; - case '!t ': - window.open( - `https://www.torrentdownloads.me/search/?search=${query.substring(3)}`, - open_in - ); - break; - case '!os': - break; - default: - window.open( - `${ - queryUrl.includes('%s') ? queryUrl.replace('%s', query) : `${queryUrl}${query}` - }`, - open_in - ); - break; - } - }, 500); - })} - > + 0 && opened} + opened={OverseerrResults.length > 0 && opened && searchQuery.length > 3} position="bottom" - withArrow withinPortal shadow="md" radius="md" zIndex={100} - trapFocus transition="pop-top-right" > - setOpened(true)} autoFocus - variant="filled" - itemComponent={AutoCompleteItem} - onItemSubmit={(item) => { - setOpened(false); - if (item.url) { - results.splice(0, autocompleteData.length); - form.reset(); - window.open(item.url); + rightSection={} + placeholder={t(`searchEngines.${selectedSearchEngine.value}.description`)} + value={searchQuery} + onChange={(event) => tryMatchSearchEngine(event.currentTarget.value, setSearchQuery)} + // Replace %s if it is in selectedSearchEngine.url with searchQuery, otherwise append searchQuery at the end of it + onKeyDown={(event) => { + if (event.key === 'Enter' && searchQuery.length > 0) { + if (selectedSearchEngine.url.includes('%s')) { + window.open(selectedSearchEngine.url.replace('%s', searchQuery), openInNewTab); + } else { + window.open(selectedSearchEngine.url + searchQuery, openInNewTab); + } } }} - data={autocompleteData} - icon={icon} - ref={textInput} - rightSectionWidth={90} - rightSection={ -
- Ctrl - + - K -
- } - radius="md" - size="md" - styles={{ rightSection: { pointerEvents: 'none' } }} - placeholder={t('input.placeholder')} - {...props} - {...form.getInputProps('query')} />
- -
- +
+ {OverseerrResults.slice(0, 5).map((result, index) => ( @@ -241,6 +164,71 @@ export default function SearchBar(props: any) {
- + ); + + function tryMatchSearchEngine(query: string, setSearchQuery: (value: string) => void) { + const foundSearchEngine = searchEnginesList.find( + (engine) => query.includes(`!${engine.shortcut}`) && !engine.disabled + ); + if (foundSearchEngine) { + setSearchQuery(query.replace(`!${foundSearchEngine.shortcut}`, '')); + changeSearchEngine(foundSearchEngine); + } else { + setSearchQuery(query); + } + } + + function SearchModuleMenu() { + return ( + + + {selectedSearchEngine.icon} + + + + {searchEnginesList.map((item) => ( + + !{item.shortcut}} + disabled={item.disabled} + onClick={() => { + changeSearchEngine(item); + }} + > + {item.label} + + + ))} + + + + {t('tip')} mod+k{' '} + + + + + ); + } + + function changeSearchEngine(item: ItemProps) { + setSearchEngine(item); + showNotification({ + radius: 'lg', + disallowClose: true, + id: 'spotlight', + autoClose: 1000, + icon: {item.icon}, + message: t('switchedSearchEngine', { searchEngine: t(`searchEngines.${item.value}.name`) }), + }); + } } diff --git a/src/modules/spotlight/SpotlightModule.tsx b/src/modules/spotlight/SpotlightModule.tsx deleted file mode 100644 index 3fb03528e..000000000 --- a/src/modules/spotlight/SpotlightModule.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { - ActionIcon, - Group, - Kbd, - Select, - Text, - TextInput, -} from '@mantine/core'; -import { - IconSearch, - IconBrandYoutube, - IconDownload, - IconMovie, - IconBrandGoogle, -} from '@tabler/icons'; -import { forwardRef, useState } from 'react'; -import { useHotkeys } from '@mantine/hooks'; -import { showNotification } from '@mantine/notifications'; -import { IModule } from '../ModuleTypes'; - -export const SpotlightModule: IModule = { - title: 'Spotlight', - icon: IconSearch, - component: SpotlightModuleComponent, - id: 'spotlight', -}; - -const searchEngines = [ - { - label: 'Google', - disabled: false, - description: 'Search using your search engine (defined in settings)', - icon: , - url: 'https://www.google.com/search?q=', - shortcut: 'g', - }, - { - label: 'Youtube', - disabled: false, - description: 'Search Youtube', - icon: , - url: 'https://www.youtube.com/results?search_query=', - shortcut: 'y', - }, - { - label: 'Download', - disabled: false, - description: 'Search for torrents', - icon: , - url: 'https://1337x.to/search/', - shortcut: 't', - }, - { - label: 'Movies', - disabled: true, - description: 'Search for movies using Overseerr', - icon: , - url: 'https://www.imdb.com/find?q=', - shortcut: 'm', - }, -]; - -const data: ItemProps[] = [ - { - icon: , - disabled: false, - label: 'Google', - value: 'google', - description: 'Search using your search engine (defined in settings)', - url: 'https://www.google.com/search?q=', - shortcut: 'g', - }, - - { - icon: , - disabled: false, - label: 'Download', - value: 'download', - description: 'Search for torrents', - url: 'https://1337x.to/search/', - shortcut: 't', - }, - { - icon: , - disabled: false, - label: 'Youtube', - value: 'youtube', - description: 'Search Youtube', - url: 'https://www.youtube.com/results?search_query=', - shortcut: 'y', - }, - { - icon: , - disabled: true, - label: 'Movies', - value: 'movies', - description: 'Search for movies using Overseerr', - url: 'https://www.imdb.com/find?q=', - shortcut: 'm', - }, -]; - -interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { - label: string; - disabled: boolean; - value: string; - description: string; - icon: React.ReactNode; - url: string; - shortcut: string; -} - -export function SpotlightModuleComponent(props: any) { - const [selectedSearchEngine, setSearchEngine] = useState(data[0]); - - const SelectItem = forwardRef( - ({ icon, label, description, shortcut, ...others }: ItemProps, ref) => ( -
- - - {icon} -
- {label} - - {description} - -
-
- {shortcut} -
-
- ) - ); - - const [opened, setOpened] = useState(false); - useHotkeys([['mod+K', () => setOpened(!opened)]]); - return ( - -