ente/src/components/SearchBar.tsx

335 lines
10 KiB
TypeScript
Raw Normal View History

2021-05-18 14:20:15 +00:00
import { SetCollections, SetFiles } from 'pages/gallery';
2021-05-20 09:04:48 +00:00
import React, { useEffect, useState, useRef } from 'react';
2021-05-18 04:32:37 +00:00
import styled from 'styled-components';
import AsyncSelect from 'react-select/async';
import { components } from 'react-select';
2021-05-24 12:04:37 +00:00
import debounce from 'debounce-promise';
2021-05-20 08:00:02 +00:00
import { File, getLocalFiles } from 'services/fileService';
import {
Collection,
getLocalCollections,
getNonEmptyCollections,
} from 'services/collectionService';
import { Bbox, parseHumanDate, searchLocation } from 'services/searchService';
import {
getFilesWithCreationDay,
getFilesInsideBbox,
2021-05-24 12:19:18 +00:00
getFormattedDate,
2021-05-26 11:37:01 +00:00
getDefaultSuggestions,
} from 'utils/search';
import constants from 'utils/strings/constants';
import LocationIcon from './LocationIcon';
import DateIcon from './DateIcon';
import SearchIcon from './SearchIcon';
2021-05-25 08:49:20 +00:00
import CrossIcon from './CrossIcon';
2021-05-18 04:32:37 +00:00
const Wrapper = styled.div<{ width: number }>`
2021-05-26 06:21:50 +00:00
position: fixed;
z-index: 1000;
top: 0;
left: ${(props) => `max(0px, 50% - min(360px,${props.width / 2}px))`};
2021-05-26 06:21:50 +00:00
width: 100%;
2021-05-26 11:30:08 +00:00
max-width: 720px;
2021-05-26 06:21:50 +00:00
display: flex;
align-items: center;
justify-content: center;
padding: 0 5%;
2021-05-18 04:32:37 +00:00
background-color: #111;
color: #fff;
min-height: 64px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.7);
margin-bottom: 10px;
2021-05-26 06:21:50 +00:00
`;
const SearchButton = styled.div`
top: 1px;
z-index: 100;
right: 80px;
color: #fff;
cursor: pointer;
min-height: 64px;
2021-05-18 04:32:37 +00:00
position: fixed;
2021-05-18 07:47:03 +00:00
display: flex;
align-items: center;
justify-content: center;
2021-05-18 04:32:37 +00:00
`;
2021-05-26 07:10:36 +00:00
const SearchStats = styled.div`
display: flex;
2021-05-26 07:50:52 +00:00
justify-content: center;
2021-05-26 07:10:36 +00:00
align-items: center;
2021-05-26 07:50:52 +00:00
color: #979797;
2021-05-26 07:10:36 +00:00
`;
2021-05-26 07:47:19 +00:00
export enum SuggestionType {
DATE,
LOCATION,
}
2021-05-26 07:47:19 +00:00
export interface Suggestion {
2021-05-24 12:19:18 +00:00
type: SuggestionType;
label: string;
value: Bbox | Date;
2021-05-18 07:47:03 +00:00
}
interface Props {
2021-05-18 14:20:15 +00:00
isOpen: boolean;
2021-05-26 14:55:06 +00:00
isFirstLoad: boolean;
2021-05-18 07:47:03 +00:00
setOpen: (value) => void;
2021-05-18 14:20:15 +00:00
loadingBar: any;
setFiles: SetFiles;
setCollections: SetCollections;
2021-05-18 07:47:03 +00:00
}
2021-05-26 07:10:36 +00:00
interface Stats {
resultCount: number;
timeTaken: number;
2021-05-26 07:10:36 +00:00
}
2021-05-18 07:47:03 +00:00
export default function SearchBar(props: Props) {
2021-05-20 08:00:02 +00:00
const [allFiles, setAllFiles] = useState<File[]>([]);
const [allCollections, setAllCollections] = useState<Collection[]>([]);
2021-05-26 06:21:50 +00:00
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
2021-05-26 07:10:36 +00:00
const [stats, setStats] = useState<Stats>(null);
const selectRef = useRef(null);
2021-05-20 08:00:02 +00:00
useEffect(() => {
2021-05-26 12:49:39 +00:00
if (props.isOpen) {
setTimeout(() => {
selectRef.current?.focus();
}, 250);
}
if (!props.isOpen && allFiles?.length > 0) {
return;
}
2021-05-20 08:00:02 +00:00
const main = async () => {
setAllFiles(await getLocalFiles());
setAllCollections(await getLocalCollections());
};
main();
2021-05-24 13:54:14 +00:00
}, [props.isOpen]);
2021-05-18 14:20:15 +00:00
2021-05-26 06:21:50 +00:00
useEffect(() => {
window.addEventListener('resize', () =>
setWindowWidth(window.innerWidth)
);
});
//==========================
// Functionality
//==========================
2021-05-26 11:37:01 +00:00
const getAutoCompleteSuggestions = async (searchPhrase: string) => {
let option = getDefaultSuggestions().filter((suggestion) =>
2021-05-26 11:36:05 +00:00
suggestion.label.toLowerCase().includes(searchPhrase.toLowerCase())
);
2021-05-24 12:22:05 +00:00
if (!searchPhrase?.length) {
return option;
}
const searchedDate = parseHumanDate(searchPhrase);
if (searchedDate != null) {
option.push({
2021-05-24 12:19:18 +00:00
type: SuggestionType.DATE,
value: searchedDate,
2021-05-24 12:19:18 +00:00
label: getFormattedDate(searchedDate),
});
}
const searchResults = await searchLocation(searchPhrase);
option.push(
...searchResults.map(
(searchResult) =>
2021-05-25 11:06:14 +00:00
({
type: SuggestionType.LOCATION,
value: searchResult.bbox,
label: searchResult.place,
2021-05-25 11:06:14 +00:00
} as Suggestion)
)
);
return option;
};
2021-05-20 08:19:37 +00:00
2021-05-26 11:37:01 +00:00
const getOptions = debounce(getAutoCompleteSuggestions, 250);
2021-05-24 12:04:37 +00:00
2021-05-24 12:19:18 +00:00
const filterFiles = (selectedOption: Suggestion) => {
if (!selectedOption) {
return;
}
2021-05-26 07:10:36 +00:00
const startTime = Date.now();
props.setOpen(true);
2021-05-20 08:00:02 +00:00
let resultFiles: File[] = [];
2021-05-20 08:19:37 +00:00
switch (selectedOption.type) {
2021-05-24 12:19:18 +00:00
case SuggestionType.DATE:
const searchedDate = selectedOption.value as Date;
const filesWithSameDate = getFilesWithCreationDay(
allFiles,
searchedDate
);
resultFiles = filesWithSameDate;
break;
2021-05-24 12:19:18 +00:00
case SuggestionType.LOCATION:
const bbox = selectedOption.value as Bbox;
2021-05-20 08:19:37 +00:00
const filesTakenAtLocation = getFilesInsideBbox(allFiles, bbox);
resultFiles = filesTakenAtLocation;
2021-05-20 08:00:02 +00:00
}
props.setFiles(resultFiles);
props.setCollections(
getNonEmptyCollections(allCollections, resultFiles)
);
2021-05-26 07:10:36 +00:00
const timeTaken = (Date.now() - startTime) / 1000;
setStats({
timeTaken,
2021-05-26 07:10:36 +00:00
resultCount: resultFiles.length,
});
2021-05-18 14:20:15 +00:00
};
const resetSearch = () => {
2021-05-25 08:49:20 +00:00
if (props.isOpen) {
2021-05-25 09:16:20 +00:00
selectRef.current.select.state.value = null;
2021-05-25 08:49:20 +00:00
props.loadingBar.current?.continuousStart();
props.setFiles(allFiles);
props.setCollections(allCollections);
setTimeout(() => {
props.loadingBar.current?.complete();
2021-05-25 09:16:20 +00:00
}, 10);
2021-05-25 08:49:20 +00:00
props.setOpen(false);
2021-05-26 12:37:54 +00:00
setStats(null);
2021-05-25 08:49:20 +00:00
}
2021-05-18 14:20:15 +00:00
};
//==========================
// UI
//==========================
2021-05-24 12:19:18 +00:00
const getIconByType = (type: SuggestionType) =>
type === SuggestionType.DATE ? <DateIcon /> : <LocationIcon />;
2021-05-21 13:29:06 +00:00
2021-05-24 12:19:18 +00:00
const LabelWithIcon = (props: { type: SuggestionType; label: string }) => (
2021-05-21 13:29:06 +00:00
<div style={{ display: 'flex', alignItems: 'center' }}>
2021-05-26 12:59:32 +00:00
<span style={{ marginRight: '10px', padding: '2px' }}>
2021-05-21 13:29:06 +00:00
{getIconByType(props.type)}
</span>
<span>{props.label}</span>
</div>
);
const { Option, SingleValue, Control } = components;
const SingleValueWithIcon = (props) => (
<SingleValue {...props}>
2021-05-21 13:29:06 +00:00
<LabelWithIcon type={props.data.type} label={props.data.label} />
</SingleValue>
);
const OptionWithIcon = (props) => (
<Option {...props}>
2021-05-21 13:29:06 +00:00
<LabelWithIcon type={props.data.type} label={props.data.label} />
</Option>
);
const ControlWithIcon = (props) => (
<Control {...props}>
2021-05-25 10:29:10 +00:00
<span
2021-05-25 11:40:46 +00:00
className={'icon'}
2021-05-25 10:29:10 +00:00
style={{
paddingLeft: '10px',
}}
>
<SearchIcon />
</span>
{props.children}
</Control>
);
const customStyles = {
control: (style, { isFocused }) => ({
...style,
backgroundColor: '#282828',
color: '#d1d1d1',
borderColor: isFocused ? '#2dc262' : '#444',
boxShadow: isFocused && '0 0 3px #2dc262',
':hover': {
borderColor: '#2dc262',
cursor: 'text',
2021-05-25 11:40:46 +00:00
'&>.icon': { color: '#2dc262' },
},
'&>.icon': { color: props.isOpen && '#2dc262' },
}),
input: (style) => ({
...style,
color: '#d1d1d1',
}),
menu: (style) => ({
...style,
2021-05-21 13:29:06 +00:00
marginTop: '10px',
backgroundColor: '#282828',
}),
option: (style, { isFocused }) => ({
...style,
backgroundColor: isFocused && '#343434',
}),
dropdownIndicator: (style) => ({
...style,
display: 'none',
}),
indicatorSeparator: (style) => ({
...style,
display: 'none',
}),
clearIndicator: (style) => ({
...style,
2021-05-25 10:29:10 +00:00
display: 'none',
}),
2021-05-25 11:06:14 +00:00
singleValue: (style, state) => ({
...style,
backgroundColor: '#282828',
color: '#d1d1d1',
2021-05-25 11:06:14 +00:00
display: state.selectProps.menuIsOpen ? 'none' : 'block',
}),
2021-05-25 09:47:57 +00:00
placeholder: (style) => ({
...style,
color: '#686868',
wordSpacing: '2px',
2021-05-25 11:06:14 +00:00
}),
};
2021-05-26 07:10:36 +00:00
return (
2021-05-26 14:55:06 +00:00
!props.isFirstLoad && (
<>
{windowWidth > 1000 || props.isOpen ? (
<Wrapper width={windowWidth}>
<div
style={{
flex: 1,
margin: '10px',
2021-05-26 07:10:36 +00:00
}}
2021-05-26 14:55:06 +00:00
>
<AsyncSelect
components={{
Option: OptionWithIcon,
SingleValue: SingleValueWithIcon,
Control: ControlWithIcon,
}}
ref={selectRef}
placeholder={constants.SEARCH_HINT()}
loadOptions={getOptions}
onChange={filterFiles}
isClearable
escapeClearsValue
styles={customStyles}
noOptionsMessage={() => null}
/>
</div>
<div style={{ width: '24px' }}>
{props.isOpen && (
<div
style={{ cursor: 'pointer' }}
onClick={resetSearch}
>
<CrossIcon />
</div>
)}
</div>
</Wrapper>
) : (
<SearchButton onClick={() => props.setOpen(true)}>
<SearchIcon />
</SearchButton>
)}
{props.isOpen && stats && (
<SearchStats>{constants.SEARCH_STATS(stats)}</SearchStats>
)}
</>
)
2021-05-18 07:47:03 +00:00
);
2021-05-18 04:32:37 +00:00
}