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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
search.person &&
|
||||||
|
search.person.files.indexOf(item.id) === -1
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSharedFile(item) && !isSharedCollection) {
|
if (isSharedFile(item) && !isSharedCollection) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,9 +36,7 @@ import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import CloseIcon from 'components/icons/CloseIcon';
|
import CloseIcon from 'components/icons/CloseIcon';
|
||||||
import TickIcon from 'components/icons/TickIcon';
|
import TickIcon from 'components/icons/TickIcon';
|
||||||
import TFJSImage from 'components/TFJSImage';
|
import { PhotoPeopleList } from 'components/PeopleList';
|
||||||
import { Person } from 'utils/machineLearning/types';
|
|
||||||
import { getPeopleList } from 'utils/machineLearning';
|
|
||||||
|
|
||||||
interface Iprops {
|
interface Iprops {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -70,24 +68,6 @@ const Pre = styled.pre`
|
||||||
padding: 7px 15px;
|
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) => (
|
const renderInfoItem = (label: string, value: string | JSX.Element) => (
|
||||||
<Row>
|
<Row>
|
||||||
<Label width="30%">{label}</Label>
|
<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({
|
function InfoModal({
|
||||||
showInfo,
|
showInfo,
|
||||||
handleCloseInfo,
|
handleCloseInfo,
|
||||||
|
@ -324,7 +275,10 @@ function InfoModal({
|
||||||
{constants.SHOW_MAP}
|
{constants.SHOW_MAP}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<PeopleList file={items[photoSwipe?.getCurrentIndex()]} />
|
<div>
|
||||||
|
<Legend>{constants.PEOPLE}</Legend>
|
||||||
|
</div>
|
||||||
|
<PhotoPeopleList file={items[photoSwipe?.getCurrentIndex()]} />
|
||||||
{exif && (
|
{exif && (
|
||||||
<>
|
<>
|
||||||
<ExifData exif={exif} />
|
<ExifData exif={exif} />
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { Search, SearchStats } from 'pages/gallery';
|
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 styled from 'styled-components';
|
||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
import { components } from 'react-select';
|
import { components } from 'react-select';
|
||||||
import debounce from 'debounce-promise';
|
import debounce from 'debounce-promise';
|
||||||
import {
|
import {
|
||||||
Bbox,
|
Bbox,
|
||||||
|
getAllPeopleSuggestion,
|
||||||
getHolidaySuggestion,
|
getHolidaySuggestion,
|
||||||
getYearSuggestion,
|
getYearSuggestion,
|
||||||
parseHumanDate,
|
parseHumanDate,
|
||||||
|
@ -25,6 +26,8 @@ import { File, FILE_TYPE } from 'services/fileService';
|
||||||
import ImageIcon from './icons/ImageIcon';
|
import ImageIcon from './icons/ImageIcon';
|
||||||
import VideoIcon from './icons/VideoIcon';
|
import VideoIcon from './icons/VideoIcon';
|
||||||
import { IconButton } from './Container';
|
import { IconButton } from './Container';
|
||||||
|
import { Person } from 'utils/machineLearning/types';
|
||||||
|
import { PeopleList } from './PeopleList';
|
||||||
|
|
||||||
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
|
const Wrapper = styled.div<{ isDisabled: boolean; isOpen: boolean }>`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -75,12 +78,20 @@ const SearchInput = styled.div`
|
||||||
margin: auto;
|
margin: auto;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Legend = styled.span`
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ddd;
|
||||||
|
display: inline;
|
||||||
|
padding: 20px 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
export enum SuggestionType {
|
export enum SuggestionType {
|
||||||
DATE,
|
DATE,
|
||||||
LOCATION,
|
LOCATION,
|
||||||
COLLECTION,
|
COLLECTION,
|
||||||
IMAGE,
|
IMAGE,
|
||||||
VIDEO,
|
VIDEO,
|
||||||
|
PERSON,
|
||||||
}
|
}
|
||||||
export interface DateValue {
|
export interface DateValue {
|
||||||
date?: number;
|
date?: number;
|
||||||
|
@ -90,7 +101,8 @@ export interface DateValue {
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
type: SuggestionType;
|
type: SuggestionType;
|
||||||
label: string;
|
label: string;
|
||||||
value: Bbox | DateValue | number;
|
value: Bbox | DateValue | number | Person;
|
||||||
|
hide?: boolean;
|
||||||
}
|
}
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -104,6 +116,7 @@ interface Props {
|
||||||
files: File[];
|
files: File[];
|
||||||
}
|
}
|
||||||
export default function SearchBar(props: Props) {
|
export default function SearchBar(props: Props) {
|
||||||
|
const selectRef = useRef(null);
|
||||||
const [value, setValue] = useState<Suggestion>(null);
|
const [value, setValue] = useState<Suggestion>(null);
|
||||||
|
|
||||||
const handleChange = (value) => {
|
const handleChange = (value) => {
|
||||||
|
@ -116,14 +129,14 @@ export default function SearchBar(props: Props) {
|
||||||
// Functionality
|
// Functionality
|
||||||
// = =========================
|
// = =========================
|
||||||
const getAutoCompleteSuggestions = async (searchPhrase: string) => {
|
const getAutoCompleteSuggestions = async (searchPhrase: string) => {
|
||||||
|
const options = await getAllPeopleSuggestion();
|
||||||
|
// const options = [];
|
||||||
searchPhrase = searchPhrase.trim().toLowerCase();
|
searchPhrase = searchPhrase.trim().toLowerCase();
|
||||||
if (!searchPhrase?.length) {
|
if (!searchPhrase?.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const options = [
|
options.push(...getHolidaySuggestion(searchPhrase));
|
||||||
...getHolidaySuggestion(searchPhrase),
|
options.push(...getYearSuggestion(searchPhrase));
|
||||||
...getYearSuggestion(searchPhrase),
|
|
||||||
];
|
|
||||||
|
|
||||||
const searchedDates = parseHumanDate(searchPhrase);
|
const searchedDates = parseHumanDate(searchPhrase);
|
||||||
|
|
||||||
|
@ -178,6 +191,7 @@ export default function SearchBar(props: Props) {
|
||||||
const getOptions = debounce(getAutoCompleteSuggestions, 250);
|
const getOptions = debounce(getAutoCompleteSuggestions, 250);
|
||||||
|
|
||||||
const search = (selectedOption: Suggestion) => {
|
const search = (selectedOption: Suggestion) => {
|
||||||
|
// console.log('search...');
|
||||||
if (!selectedOption) {
|
if (!selectedOption) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -203,6 +217,10 @@ export default function SearchBar(props: Props) {
|
||||||
props.setSearch({ fileIndex: selectedOption.value as number });
|
props.setSearch({ fileIndex: selectedOption.value as number });
|
||||||
setValue(null);
|
setValue(null);
|
||||||
break;
|
break;
|
||||||
|
case SuggestionType.PERSON:
|
||||||
|
props.setSearch({ person: selectedOption.value as Person });
|
||||||
|
props.setOpen(true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const resetSearch = () => {
|
const resetSearch = () => {
|
||||||
|
@ -246,13 +264,17 @@ export default function SearchBar(props: Props) {
|
||||||
<span>{props.label}</span>
|
<span>{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const { Option, Control } = components;
|
const { Option, Control, Menu } = components;
|
||||||
|
|
||||||
const OptionWithIcon = (props) => (
|
const OptionWithIcon = (props) =>
|
||||||
<Option {...props}>
|
!props.data.hide && (
|
||||||
<LabelWithIcon type={props.data.type} label={props.data.label} />
|
<Option {...props}>
|
||||||
</Option>
|
<LabelWithIcon
|
||||||
);
|
type={props.data.type}
|
||||||
|
label={props.data.label}
|
||||||
|
/>
|
||||||
|
</Option>
|
||||||
|
);
|
||||||
const ControlWithIcon = (props) => (
|
const ControlWithIcon = (props) => (
|
||||||
<Control {...props}>
|
<Control {...props}>
|
||||||
<span
|
<span
|
||||||
|
@ -267,6 +289,30 @@ export default function SearchBar(props: Props) {
|
||||||
</Control>
|
</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 = {
|
const customStyles = {
|
||||||
control: (style, { isFocused }) => ({
|
control: (style, { isFocused }) => ({
|
||||||
...style,
|
...style,
|
||||||
|
@ -333,8 +379,10 @@ export default function SearchBar(props: Props) {
|
||||||
margin: '10px',
|
margin: '10px',
|
||||||
}}>
|
}}>
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
|
ref={selectRef}
|
||||||
value={value}
|
value={value}
|
||||||
components={{
|
components={{
|
||||||
|
Menu: CustomMenu,
|
||||||
Option: OptionWithIcon,
|
Option: OptionWithIcon,
|
||||||
Control: ControlWithIcon,
|
Control: ControlWithIcon,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -93,6 +93,7 @@ import {
|
||||||
Trash,
|
Trash,
|
||||||
} from 'services/trashService';
|
} from 'services/trashService';
|
||||||
import DeleteBtn from 'components/DeleteBtn';
|
import DeleteBtn from 'components/DeleteBtn';
|
||||||
|
import { Person } from 'utils/machineLearning/types';
|
||||||
|
|
||||||
export const DeadCenter = styled.div`
|
export const DeadCenter = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -123,6 +124,7 @@ export type Search = {
|
||||||
date?: DateValue;
|
date?: DateValue;
|
||||||
location?: Bbox;
|
location?: Bbox;
|
||||||
fileIndex?: number;
|
fileIndex?: number;
|
||||||
|
person?: Person;
|
||||||
};
|
};
|
||||||
export interface SearchStats {
|
export interface SearchStats {
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Collection } from './collectionService';
|
||||||
import { File } from './fileService';
|
import { File } from './fileService';
|
||||||
import { User } from './userService';
|
import { User } from './userService';
|
||||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
import { getAllPeople } from 'utils/machineLearning';
|
||||||
|
|
||||||
const ENDPOINT = getEndpoint();
|
const ENDPOINT = getEndpoint();
|
||||||
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
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 [];
|
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(
|
export function searchCollection(
|
||||||
searchPhrase: string,
|
searchPhrase: string,
|
||||||
collections: Collection[]
|
collections: Collection[]
|
||||||
|
|
|
@ -200,6 +200,15 @@ export async function getPeopleList(file: File): Promise<Array<Person>> {
|
||||||
return peopleList;
|
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>(
|
export function findFirstIfSorted<T>(
|
||||||
elements: Array<T>,
|
elements: Array<T>,
|
||||||
comparator: (a: T, b: T) => number
|
comparator: (a: T, b: T) => number
|
||||||
|
|
Loading…
Reference in a new issue