Add people to search, filter photos by person
This commit is contained in:
parent
af130f803b
commit
1d41644ac8
95
src/components/PeopleList.tsx
Normal file
95
src/components/PeopleList.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import TFJSImage from 'components/TFJSImage';
|
||||
import { Person } from 'utils/machineLearning/types';
|
||||
import { getAllPeople, getPeopleList } from 'utils/machineLearning';
|
||||
import styled from 'styled-components';
|
||||
import { File } from 'services/fileService';
|
||||
|
||||
const FaceChipContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const FaceChip = styled.div`
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
margin-right: 10px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
interface PeopleListPropsBase {
|
||||
onSelect?: (person: Person, index: number) => void;
|
||||
}
|
||||
|
||||
export interface PeopleListProps extends PeopleListPropsBase {
|
||||
people: Array<Person>;
|
||||
}
|
||||
|
||||
export function PeopleList(props: PeopleListProps) {
|
||||
return (
|
||||
<FaceChipContainer>
|
||||
{props.people.map((person, index) => (
|
||||
<FaceChip
|
||||
key={index}
|
||||
onClick={() =>
|
||||
props.onSelect && props.onSelect(person, index)
|
||||
}>
|
||||
<TFJSImage faceImage={person.faceImage}></TFJSImage>
|
||||
</FaceChip>
|
||||
))}
|
||||
</FaceChipContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhotoPeopleListProps extends PeopleListPropsBase {
|
||||
file: File;
|
||||
}
|
||||
|
||||
export function PhotoPeopleList(props: PhotoPeopleListProps) {
|
||||
const [people, setPeople] = useState<Array<Person>>([]);
|
||||
|
||||
const updateFaceImages = async () => {
|
||||
const people = await getPeopleList(props.file);
|
||||
setPeople(people);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: handle multiple async updates
|
||||
updateFaceImages();
|
||||
}, [props.file]);
|
||||
|
||||
return <PeopleList people={people} onSelect={props.onSelect}></PeopleList>;
|
||||
}
|
||||
|
||||
export interface AllPeopleListProps extends PeopleListPropsBase {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function AllPeopleList(props: AllPeopleListProps) {
|
||||
const [people, setPeople] = useState<Array<Person>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: handle multiple async updates
|
||||
async function updateFaceImages() {
|
||||
let people = await getAllPeople();
|
||||
if (props.limit) {
|
||||
people = people.slice(0, props.limit);
|
||||
}
|
||||
setPeople(people);
|
||||
}
|
||||
|
||||
updateFaceImages();
|
||||
return () => {
|
||||
setPeople([]);
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return <PeopleList people={people} onSelect={props.onSelect}></PeopleList>;
|
||||
}
|
|
@ -186,6 +186,13 @@ const PhotoFrame = ({
|
|||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
search.person &&
|
||||
search.person.files.indexOf(item.id) === -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSharedFile(item) && !isSharedCollection) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -36,9 +36,7 @@ import DatePicker from 'react-datepicker';
|
|||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import CloseIcon from 'components/icons/CloseIcon';
|
||||
import TickIcon from 'components/icons/TickIcon';
|
||||
import TFJSImage from 'components/TFJSImage';
|
||||
import { Person } from 'utils/machineLearning/types';
|
||||
import { getPeopleList } from 'utils/machineLearning';
|
||||
import { PhotoPeopleList } from 'components/PeopleList';
|
||||
|
||||
interface Iprops {
|
||||
isOpen: boolean;
|
||||
|
@ -70,24 +68,6 @@ const Pre = styled.pre`
|
|||
padding: 7px 15px;
|
||||
`;
|
||||
|
||||
const FaceChipContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const FaceChip = styled.div`
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
margin-right: 10px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const renderInfoItem = (label: string, value: string | JSX.Element) => (
|
||||
<Row>
|
||||
<Label width="30%">{label}</Label>
|
||||
|
@ -249,35 +229,6 @@ function ExifData(props: { exif: any }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PeopleList(props: { file }) {
|
||||
const [peopleList, setPeopleList] = useState<Array<Person>>([]);
|
||||
|
||||
const updateFaceImages = async () => {
|
||||
const peopleList = await getPeopleList(props.file);
|
||||
setPeopleList(peopleList);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: handle multiple async updates
|
||||
updateFaceImages();
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Legend>{constants.PEOPLE}</Legend>
|
||||
</div>
|
||||
<FaceChipContainer>
|
||||
{peopleList.map((person, index) => (
|
||||
<FaceChip key={index}>
|
||||
<TFJSImage faceImage={person.faceImage}></TFJSImage>
|
||||
</FaceChip>
|
||||
))}
|
||||
</FaceChipContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoModal({
|
||||
showInfo,
|
||||
handleCloseInfo,
|
||||
|
@ -324,7 +275,10 @@ function InfoModal({
|
|||
{constants.SHOW_MAP}
|
||||
</a>
|
||||
)}
|
||||
<PeopleList file={items[photoSwipe?.getCurrentIndex()]} />
|
||||
<div>
|
||||
<Legend>{constants.PEOPLE}</Legend>
|
||||
</div>
|
||||
<PhotoPeopleList file={items[photoSwipe?.getCurrentIndex()]} />
|
||||
{exif && (
|
||||
<>
|
||||
<ExifData exif={exif} />
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Search, SearchStats } from 'pages/gallery';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, 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 {
|
||||
Bbox,
|
||||
getAllPeopleSuggestion,
|
||||
getHolidaySuggestion,
|
||||
getYearSuggestion,
|
||||
parseHumanDate,
|
||||
|
@ -25,6 +26,8 @@ import { File, FILE_TYPE } from 'services/fileService';
|
|||
import ImageIcon from './icons/ImageIcon';
|
||||
import VideoIcon from './icons/VideoIcon';
|
||||
import { IconButton } from './Container';
|
||||
import { Person } from 'utils/machineLearning/types';
|
||||
import { PeopleList } from './PeopleList';
|
||||
|
||||
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
|
||||
position: fixed;
|
||||
|
@ -75,12 +78,20 @@ const SearchInput = styled.div`
|
|||
margin: auto;
|
||||
`;
|
||||
|
||||
const Legend = styled.span`
|
||||
font-size: 20px;
|
||||
color: #ddd;
|
||||
display: inline;
|
||||
padding: 20px 10px;
|
||||
`;
|
||||
|
||||
export enum SuggestionType {
|
||||
DATE,
|
||||
LOCATION,
|
||||
COLLECTION,
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
PERSON,
|
||||
}
|
||||
export interface DateValue {
|
||||
date?: number;
|
||||
|
@ -90,7 +101,8 @@ export interface DateValue {
|
|||
export interface Suggestion {
|
||||
type: SuggestionType;
|
||||
label: string;
|
||||
value: Bbox | DateValue | number;
|
||||
value: Bbox | DateValue | number | Person;
|
||||
hide?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
|
@ -104,6 +116,7 @@ interface Props {
|
|||
files: File[];
|
||||
}
|
||||
export default function SearchBar(props: Props) {
|
||||
const selectRef = useRef(null);
|
||||
const [value, setValue] = useState<Suggestion>(null);
|
||||
|
||||
const handleChange = (value) => {
|
||||
|
@ -116,14 +129,14 @@ export default function SearchBar(props: Props) {
|
|||
// Functionality
|
||||
// = =========================
|
||||
const getAutoCompleteSuggestions = async (searchPhrase: string) => {
|
||||
const options = await getAllPeopleSuggestion();
|
||||
// const options = [];
|
||||
searchPhrase = searchPhrase.trim().toLowerCase();
|
||||
if (!searchPhrase?.length) {
|
||||
return [];
|
||||
}
|
||||
const options = [
|
||||
...getHolidaySuggestion(searchPhrase),
|
||||
...getYearSuggestion(searchPhrase),
|
||||
];
|
||||
options.push(...getHolidaySuggestion(searchPhrase));
|
||||
options.push(...getYearSuggestion(searchPhrase));
|
||||
|
||||
const searchedDates = parseHumanDate(searchPhrase);
|
||||
|
||||
|
@ -178,6 +191,7 @@ export default function SearchBar(props: Props) {
|
|||
const getOptions = debounce(getAutoCompleteSuggestions, 250);
|
||||
|
||||
const search = (selectedOption: Suggestion) => {
|
||||
// console.log('search...');
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
@ -203,6 +217,10 @@ export default function SearchBar(props: Props) {
|
|||
props.setSearch({ fileIndex: selectedOption.value as number });
|
||||
setValue(null);
|
||||
break;
|
||||
case SuggestionType.PERSON:
|
||||
props.setSearch({ person: selectedOption.value as Person });
|
||||
props.setOpen(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
const resetSearch = () => {
|
||||
|
@ -246,11 +264,15 @@ export default function SearchBar(props: Props) {
|
|||
<span>{props.label}</span>
|
||||
</div>
|
||||
);
|
||||
const { Option, Control } = components;
|
||||
const { Option, Control, Menu } = components;
|
||||
|
||||
const OptionWithIcon = (props) => (
|
||||
const OptionWithIcon = (props) =>
|
||||
!props.data.hide && (
|
||||
<Option {...props}>
|
||||
<LabelWithIcon type={props.data.type} label={props.data.label} />
|
||||
<LabelWithIcon
|
||||
type={props.data.type}
|
||||
label={props.data.label}
|
||||
/>
|
||||
</Option>
|
||||
);
|
||||
const ControlWithIcon = (props) => (
|
||||
|
@ -267,6 +289,30 @@ export default function SearchBar(props: Props) {
|
|||
</Control>
|
||||
);
|
||||
|
||||
const CustomMenu = (props) => {
|
||||
// console.log("props.selectProps.options: ", selectRef);
|
||||
const peopleSuggestions = props.selectProps.options.filter(
|
||||
(o) => o.type === SuggestionType.PERSON
|
||||
);
|
||||
const people = peopleSuggestions.map((o) => o.value);
|
||||
return (
|
||||
<Menu {...props}>
|
||||
{people && people.length > 0 && (
|
||||
<>
|
||||
<Legend>{constants.PEOPLE}</Legend>
|
||||
<PeopleList
|
||||
people={people}
|
||||
onSelect={(person, index) => {
|
||||
selectRef.current.blur();
|
||||
setValue(peopleSuggestions[index]);
|
||||
}}></PeopleList>
|
||||
</>
|
||||
)}
|
||||
{props.children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const customStyles = {
|
||||
control: (style, { isFocused }) => ({
|
||||
...style,
|
||||
|
@ -333,8 +379,10 @@ export default function SearchBar(props: Props) {
|
|||
margin: '10px',
|
||||
}}>
|
||||
<AsyncSelect
|
||||
ref={selectRef}
|
||||
value={value}
|
||||
components={{
|
||||
Menu: CustomMenu,
|
||||
Option: OptionWithIcon,
|
||||
Control: ControlWithIcon,
|
||||
}}
|
||||
|
|
|
@ -93,6 +93,7 @@ import {
|
|||
Trash,
|
||||
} from 'services/trashService';
|
||||
import DeleteBtn from 'components/DeleteBtn';
|
||||
import { Person } from 'utils/machineLearning/types';
|
||||
|
||||
export const DeadCenter = styled.div`
|
||||
flex: 1;
|
||||
|
@ -123,6 +124,7 @@ export type Search = {
|
|||
date?: DateValue;
|
||||
location?: Bbox;
|
||||
fileIndex?: number;
|
||||
person?: Person;
|
||||
};
|
||||
export interface SearchStats {
|
||||
resultCount: number;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Collection } from './collectionService';
|
|||
import { File } from './fileService';
|
||||
import { User } from './userService';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { getAllPeople } from 'utils/machineLearning';
|
||||
|
||||
const ENDPOINT = getEndpoint();
|
||||
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
||||
|
@ -105,6 +106,16 @@ export function getYearSuggestion(searchPhrase: string): Suggestion[] {
|
|||
return [];
|
||||
}
|
||||
|
||||
export async function getAllPeopleSuggestion(): Promise<Array<Suggestion>> {
|
||||
const people = await getAllPeople();
|
||||
return people.slice(0, 6).map((person) => ({
|
||||
label: person.name,
|
||||
type: SuggestionType.PERSON,
|
||||
value: person,
|
||||
hide: true,
|
||||
}));
|
||||
}
|
||||
|
||||
export function searchCollection(
|
||||
searchPhrase: string,
|
||||
collections: Collection[]
|
||||
|
|
|
@ -200,6 +200,15 @@ export async function getPeopleList(file: File): Promise<Array<Person>> {
|
|||
return peopleList;
|
||||
}
|
||||
|
||||
export async function getAllPeople() {
|
||||
const people: Array<Person> = [];
|
||||
await mlPeopleStore.iterate<Person, void>((person) => {
|
||||
people.push(person);
|
||||
});
|
||||
|
||||
return people.sort((p1, p2) => p2.files.length - p1.files.length);
|
||||
}
|
||||
|
||||
export function findFirstIfSorted<T>(
|
||||
elements: Array<T>,
|
||||
comparator: (a: T, b: T) => number
|
||||
|
|
Loading…
Reference in a new issue