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/Logo.tsx b/src/components/layout/Logo.tsx index 6acdcb075..4800dc5e7 100644 --- a/src/components/layout/Logo.tsx +++ b/src/components/layout/Logo.tsx @@ -9,7 +9,7 @@ export function Logo({ style, withoutText }: any) { const { primaryColor, secondaryColor } = useColorTheme(); return ( - + ({ - hide: { - [theme.fn.smallerThan('xs')]: { - display: 'none', - }, - }, - burger: { - [theme.fn.largerThan('sm')]: { - display: 'none', - }, - }, -})); +import { SearchModuleComponent } from '../../../modules/search/SearchModule'; export function Header(props: any) { - const { classes } = useStyles(); + const { width } = useViewportSize(); + const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; const { config } = useConfig(); const { colorScheme } = useMantineColorScheme(); @@ -35,12 +24,10 @@ export function Header(props: any) { ${(config.settings.appOpacity || 100) / 100}`, }} > - - - - - - + + {width > MIN_WIDTH_MOBILE && } + + diff --git a/src/modules/calendar/CalendarModule.tsx b/src/modules/calendar/CalendarModule.tsx index af78d70bc..bdebf072b 100644 --- a/src/modules/calendar/CalendarModule.tsx +++ b/src/modules/calendar/CalendarModule.tsx @@ -144,12 +144,6 @@ export default function CalendarComponent(props: any) { margin: 1, } } - styles={{ - calendarHeader: { - marginRight: 40, - marginLeft: 40, - }, - }} allowLevelChange={false} dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })} renderDay={(renderdate) => ( diff --git a/src/modules/overseerr/RequestModal.tsx b/src/modules/overseerr/RequestModal.tsx index 79bfd33e1..93e6e4920 100644 --- a/src/modules/overseerr/RequestModal.tsx +++ b/src/modules/overseerr/RequestModal.tsx @@ -188,7 +188,7 @@ export function TvRequestModal({ {rows} - + diff --git a/src/modules/search/SearchModule.tsx b/src/modules/search/SearchModule.tsx index 6540178c3..0842272a4 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() { 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`) }), + }); + } }