refactor search

This commit is contained in:
Abhinav 2022-05-28 20:11:06 +05:30
parent 19dd45817c
commit e09bcdd34b
7 changed files with 307 additions and 286 deletions

View file

@ -0,0 +1,56 @@
import React from 'react';
import CollectionIcon from 'components/icons/CollectionIcon';
import DateIcon from 'components/icons/DateIcon';
import ImageIcon from 'components/icons/ImageIcon';
import LocationIcon from 'components/icons/LocationIcon';
import VideoIcon from 'components/icons/VideoIcon';
import { components } from 'react-select';
import { SuggestionType } from 'types/search';
import SearchIcon from '@mui/icons-material/Search';
const { Option, Control } = components;
const getIconByType = (type: SuggestionType) => {
switch (type) {
case SuggestionType.DATE:
return <DateIcon />;
case SuggestionType.LOCATION:
return <LocationIcon />;
case SuggestionType.COLLECTION:
return <CollectionIcon />;
case SuggestionType.IMAGE:
return <ImageIcon />;
case SuggestionType.VIDEO:
return <VideoIcon />;
default:
return <SearchIcon />;
}
};
export const OptionWithIcon = (props) => (
<Option {...props}>
<LabelWithIcon type={props.data.type} label={props.data.label} />
</Option>
);
export const ControlWithIcon = (props) => (
<Control {...props}>
<span
className="icon"
style={{
paddingLeft: '10px',
paddingBottom: '4px',
}}>
{getIconByType(props.getValue()[0]?.type)}
</span>
{props.children}
</Control>
);
const LabelWithIcon = (props: { type: SuggestionType; label: string }) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ paddingRight: '10px', paddingBottom: '4px' }}>
{getIconByType(props.type)}
</span>
<span>{props.label}</span>
</div>
);

View file

@ -1,299 +1,40 @@
import React, { useContext, useEffect, useState } from 'react';
import styled from 'styled-components';
import AsyncSelect from 'react-select/async';
import { components } from 'react-select';
import debounce from 'debounce-promise';
import {
getHolidaySuggestion,
getYearSuggestion,
parseHumanDate,
searchCollection,
searchFiles,
searchLocation,
} from 'services/searchService';
import { getFormattedDate, isInsideBox } from 'utils/search';
import constants from 'utils/strings/constants';
import LocationIcon from '../icons/LocationIcon';
import DateIcon from '../icons/DateIcon';
import SearchIcon from '../icons/SearchIcon';
import CloseIcon from '@mui/icons-material/Close';
import React from 'react';
import SearchIcon from '@mui/icons-material/Search';
import { Collection } from 'types/collection';
import CollectionIcon from '../icons/CollectionIcon';
import ImageIcon from '../icons/ImageIcon';
import VideoIcon from '../icons/VideoIcon';
import { IconButton } from '../Container';
import { EnteFile } from 'types/file';
import { Suggestion, SuggestionType, DateValue, Bbox } from 'types/search';
import { Search, SearchStats } from 'types/gallery';
import { FILE_TYPE } from 'constants/file';
import { SelectStyles } from './styles';
import { AppContext } from 'pages/_app';
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
display: ${({ isOpen }) => (isOpen ? 'flex' : 'none')};
width: 100%;
@media (min-width: 625px) {
display: flex;
width: calc(100vw - 140px);
margin: 0 70px;
}
align-items: center;
min-height: 64px;
transition: opacity 1s ease;
opacity: ${(props) => (props.isDisabled ? 0 : 1)};
margin-bottom: 10px;
`;
const SearchButton = styled.div<{ isOpen: boolean }>`
display: none;
@media (max-width: 624px) {
display: ${({ isOpen }) => (!isOpen ? 'flex' : 'none')};
cursor: pointer;
align-items: center;
min-height: 64px;
}
`;
const SearchInput = styled.div`
width: 100%;
display: flex;
align-items: center;
max-width: 484px;
margin: auto;
`;
import { Search } from 'types/gallery';
import { SearchBarWrapper, SearchButtonWrapper } from './styledComponents';
import SearchInput from './input';
import { SelectionBar } from 'components/Navbar/SelectionBar';
interface Props {
isOpen: boolean;
isFirstFetch: boolean;
setOpen: (value: boolean) => void;
setSearch: (search: Search) => void;
searchStats: SearchStats;
collections: Collection[];
setActiveCollection: (id: number) => void;
files: EnteFile[];
}
export default function SearchBar(props: Props) {
const [value, setValue] = useState<Suggestion>(null);
const appContext = useContext(AppContext);
const handleChange = (value) => {
setValue(value);
};
useEffect(() => search(value), [value]);
// = =========================
// Functionality
// = =========================
const getAutoCompleteSuggestions = async (searchPhrase: string) => {
searchPhrase = searchPhrase.trim().toLowerCase();
if (!searchPhrase?.length) {
return [];
}
const options = [
...getHolidaySuggestion(searchPhrase),
...getYearSuggestion(searchPhrase),
];
const searchedDates = parseHumanDate(searchPhrase);
options.push(
...searchedDates.map((searchedDate) => ({
type: SuggestionType.DATE,
value: searchedDate,
label: getFormattedDate(searchedDate),
}))
);
const collectionResults = searchCollection(
searchPhrase,
props.collections
);
options.push(
...collectionResults.map(
(searchResult) =>
({
type: SuggestionType.COLLECTION,
value: searchResult.id,
label: searchResult.name,
} as Suggestion)
)
);
const fileResults = searchFiles(searchPhrase, props.files);
options.push(
...fileResults.map((file) => ({
type:
file.type === FILE_TYPE.IMAGE
? SuggestionType.IMAGE
: SuggestionType.VIDEO,
value: file.index,
label: file.title,
}))
);
const locationResults = await searchLocation(searchPhrase);
const locationResultsHasFiles: boolean[] = new Array(
locationResults.length
).fill(false);
props.files.map((file) => {
for (const [index, location] of locationResults.entries()) {
if (
isInsideBox(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
location.bbox
)
) {
locationResultsHasFiles[index] = true;
}
}
});
const filteredLocationWithFiles = locationResults.filter(
(_, index) => locationResultsHasFiles[index]
);
options.push(
...filteredLocationWithFiles.map(
(searchResult) =>
({
type: SuggestionType.LOCATION,
value: searchResult.bbox,
label: searchResult.place,
} as Suggestion)
)
);
return options;
};
const getOptions = debounce(getAutoCompleteSuggestions, 250);
const search = (selectedOption: Suggestion) => {
if (!selectedOption) {
return;
}
switch (selectedOption.type) {
case SuggestionType.DATE:
props.setSearch({
date: selectedOption.value as DateValue,
});
props.setOpen(true);
break;
case SuggestionType.LOCATION:
props.setSearch({
location: selectedOption.value as Bbox,
});
props.setOpen(true);
break;
case SuggestionType.COLLECTION:
props.setActiveCollection(selectedOption.value as number);
setValue(null);
break;
case SuggestionType.IMAGE:
case SuggestionType.VIDEO:
props.setSearch({ fileIndex: selectedOption.value as number });
setValue(null);
break;
}
};
const resetSearch = () => {
if (props.isOpen) {
appContext.startLoading();
props.setSearch({});
setTimeout(() => {
appContext.finishLoading();
}, 10);
props.setOpen(false);
setValue(null);
}
};
// = =========================
// UI
// = =========================
const getIconByType = (type: SuggestionType) => {
switch (type) {
case SuggestionType.DATE:
return <DateIcon />;
case SuggestionType.LOCATION:
return <LocationIcon />;
case SuggestionType.COLLECTION:
return <CollectionIcon />;
case SuggestionType.IMAGE:
return <ImageIcon />;
case SuggestionType.VIDEO:
return <VideoIcon />;
default:
return <SearchIcon />;
}
};
const LabelWithIcon = (props: { type: SuggestionType; label: string }) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ paddingRight: '10px', paddingBottom: '4px' }}>
{getIconByType(props.type)}
</span>
<span>{props.label}</span>
</div>
);
const { Option, Control } = components;
const OptionWithIcon = (props) => (
<Option {...props}>
<LabelWithIcon type={props.data.type} label={props.data.label} />
</Option>
);
const ControlWithIcon = (props) => (
<Control {...props}>
<span
className="icon"
style={{
paddingLeft: '10px',
paddingBottom: '4px',
}}>
{getIconByType(props.getValue()[0]?.type)}
</span>
{props.children}
</Control>
);
export default function SearchBar({ isFirstFetch, ...props }: Props) {
return (
<>
<Wrapper isDisabled={props.isFirstFetch} isOpen={props.isOpen}>
<SearchInput>
<div
style={{
flex: 1,
margin: '10px',
}}>
<AsyncSelect
value={value}
components={{
Option: OptionWithIcon,
Control: ControlWithIcon,
}}
placeholder={constants.SEARCH_HINT()}
loadOptions={getOptions}
onChange={handleChange}
isClearable
escapeClearsValue
styles={SelectStyles}
noOptionsMessage={() => null}
/>
</div>
{props.isOpen && (
<IconButton onClick={() => resetSearch()}>
<CloseIcon />
</IconButton>
)}
</SearchInput>
</Wrapper>
<SearchButton
isOpen={props.isOpen}
onClick={() => !props.isFirstFetch && props.setOpen(true)}>
<SearchBarWrapper>
<SearchInput {...props} />
</SearchBarWrapper>
<SearchButtonWrapper>
<IconButton
onClick={() => !isFirstFetch && props.setOpen(true)}>
<SearchIcon />
</SearchButton>
</IconButton>
</SearchButtonWrapper>
{props.isOpen && (
<SelectionBar>
<SearchInput {...props} />
</SelectionBar>
)}
</>
);
}

View file

@ -0,0 +1,105 @@
import { IconButton } from '@mui/material';
import debounce from 'debounce-promise';
import { AppContext } from 'pages/_app';
import React, { useContext, useEffect, useState } from 'react';
import { getAutoCompleteSuggestions } from 'services/searchService';
import { Bbox, DateValue, Suggestion, SuggestionType } from 'types/search';
import constants from 'utils/strings/constants';
import { OptionWithIcon, ControlWithIcon } from './customSearchComponents';
import { SearchInputWrapper } from './styledComponents';
import { SelectStyles } from './styles';
import AsyncSelect from 'react-select/async';
import CloseIcon from '@mui/icons-material/Close';
import { SetSearch } from 'types/gallery';
import { EnteFile } from 'types/file';
import { Collection } from 'types/collection';
interface Iprops {
isOpen: boolean;
setSearch: SetSearch;
setOpen: (value: boolean) => void;
files: EnteFile[];
collections: Collection[];
setActiveCollection: (id: number) => void;
}
export default function SearchInput(props: Iprops) {
const [value, setValue] = useState<Suggestion>(null);
const appContext = useContext(AppContext);
const handleChange = (value: Suggestion) => {
setValue(value);
};
useEffect(() => search(value), [value]);
const resetSearch = () => {
if (props.isOpen) {
appContext.startLoading();
props.setSearch({});
setTimeout(() => {
appContext.finishLoading();
}, 10);
props.setOpen(false);
setValue(null);
}
};
const getOptions = debounce(
getAutoCompleteSuggestions(props.files, props.collections),
250
);
const search = (selectedOption: Suggestion) => {
if (!selectedOption) {
return;
}
switch (selectedOption.type) {
case SuggestionType.DATE:
props.setSearch({
date: selectedOption.value as DateValue,
});
props.setOpen(true);
break;
case SuggestionType.LOCATION:
props.setSearch({
location: selectedOption.value as Bbox,
});
props.setOpen(true);
break;
case SuggestionType.COLLECTION:
props.setActiveCollection(selectedOption.value as number);
setValue(null);
break;
case SuggestionType.IMAGE:
case SuggestionType.VIDEO:
props.setSearch({ fileIndex: selectedOption.value as number });
setValue(null);
break;
}
};
return (
<SearchInputWrapper>
<AsyncSelect
value={value}
components={{
Option: OptionWithIcon,
Control: ControlWithIcon,
}}
placeholder={constants.SEARCH_HINT()}
loadOptions={getOptions}
onChange={handleChange}
isClearable
escapeClearsValue
styles={SelectStyles}
noOptionsMessage={() => null}
/>
{props.isOpen && (
<IconButton onClick={() => resetSearch()} sx={{ ml: 1 }}>
<CloseIcon />
</IconButton>
)}
</SearchInputWrapper>
);
}

View file

@ -0,0 +1,31 @@
import {
CenteredFlex,
FlexWrapper,
FluidContainer,
} from 'components/Container';
import styled from 'styled-components';
export const SearchBarWrapper = styled(CenteredFlex)`
width: 100%;
@media (max-width: 624px) {
display: none;
}
`;
export const SearchButtonWrapper = styled(FluidContainer)`
display: flex;
cursor: pointer;
align-items: center;
justify-content: flex-end;
min-height: 64px;
padding: 0 20px;
@media (min-width: 624px) {
display: none;
}
`;
export const SearchInputWrapper = styled(FlexWrapper)`
width: 100%;
max-width: 484px;
margin: auto;
`;

View file

@ -1,4 +1,8 @@
export const SelectStyles = {
container: (style) => ({
...style,
flex: 1,
}),
control: (style, { isFocused }) => ({
...style,
backgroundColor: '#282828',

View file

@ -12,12 +12,95 @@ import {
Suggestion,
SuggestionType,
} from 'types/search';
import { FILE_TYPE } from 'constants/file';
import { getFormattedDate, isInsideBox } from 'utils/search';
const ENDPOINT = getEndpoint();
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
export function parseHumanDate(humanDate: string): DateValue[] {
export const getAutoCompleteSuggestions =
(files: EnteFile[], collections: Collection[]) =>
async (searchPhrase: string) => {
searchPhrase = searchPhrase.trim().toLowerCase();
if (!searchPhrase?.length) {
return [];
}
const options = [
...getHolidaySuggestion(searchPhrase),
...getYearSuggestion(searchPhrase),
];
const searchedDates = parseHumanDate(searchPhrase);
options.push(
...searchedDates.map((searchedDate) => ({
type: SuggestionType.DATE,
value: searchedDate,
label: getFormattedDate(searchedDate),
}))
);
const collectionResults = searchCollection(searchPhrase, collections);
options.push(
...collectionResults.map(
(searchResult) =>
({
type: SuggestionType.COLLECTION,
value: searchResult.id,
label: searchResult.name,
} as Suggestion)
)
);
const fileResults = searchFiles(searchPhrase, files);
options.push(
...fileResults.map((file) => ({
type:
file.type === FILE_TYPE.IMAGE
? SuggestionType.IMAGE
: SuggestionType.VIDEO,
value: file.index,
label: file.title,
}))
);
const locationResults = await searchLocation(searchPhrase);
const locationResultsHasFiles: boolean[] = new Array(
locationResults.length
).fill(false);
files.map((file) => {
for (const [index, location] of locationResults.entries()) {
if (
isInsideBox(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
location.bbox
)
) {
locationResultsHasFiles[index] = true;
}
}
});
const filteredLocationWithFiles = locationResults.filter(
(_, index) => locationResultsHasFiles[index]
);
options.push(
...filteredLocationWithFiles.map(
(searchResult) =>
({
type: SuggestionType.LOCATION,
value: searchResult.bbox,
label: searchResult.place,
} as Suggestion)
)
);
return options;
};
function parseHumanDate(humanDate: string): DateValue[] {
const date = chrono.parseDate(humanDate);
const date1 = chrono.parseDate(`${humanDate} 1`);
if (date !== null) {
@ -42,7 +125,7 @@ export function parseHumanDate(humanDate: string): DateValue[] {
return [];
}
export async function searchLocation(
async function searchLocation(
searchPhrase: string
): Promise<LocationSearchResponse[]> {
try {
@ -63,7 +146,7 @@ export async function searchLocation(
return [];
}
export function getHolidaySuggestion(searchPhrase: string): Suggestion[] {
function getHolidaySuggestion(searchPhrase: string): Suggestion[] {
return [
{
label: 'Christmas',
@ -90,7 +173,7 @@ export function getHolidaySuggestion(searchPhrase: string): Suggestion[] {
);
}
export function getYearSuggestion(searchPhrase: string): Suggestion[] {
function getYearSuggestion(searchPhrase: string): Suggestion[] {
if (searchPhrase.length === 4) {
try {
const year = parseInt(searchPhrase);
@ -110,7 +193,7 @@ export function getYearSuggestion(searchPhrase: string): Suggestion[] {
return [];
}
export function searchCollection(
function searchCollection(
searchPhrase: string,
collections: Collection[]
): Collection[] {
@ -119,7 +202,7 @@ export function searchCollection(
);
}
export function searchFiles(searchPhrase: string, files: EnteFile[]) {
function searchFiles(searchPhrase: string, files: EnteFile[]) {
return files
.map((file, idx) => ({
title: file.metadata.title,

View file

@ -11,6 +11,7 @@ export type SetFiles = React.Dispatch<React.SetStateAction<EnteFile[]>>;
export type SetCollections = React.Dispatch<React.SetStateAction<Collection[]>>;
export type SetLoading = React.Dispatch<React.SetStateAction<Boolean>>;
export type SetSearchStats = React.Dispatch<React.SetStateAction<SearchStats>>;
export type SetSearch = React.Dispatch<React.SetStateAction<Search>>;
export type Search = {
date?: DateValue;