diff --git a/ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx b/ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx new file mode 100644 index 00000000..ef9ced1e --- /dev/null +++ b/ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx @@ -0,0 +1,40 @@ +import '@testing-library/jest-dom' + +import React from 'react' +import { render, screen } from '@testing-library/react' +import FaceCircleImage from './FaceCircleImage' +import { myFaces_myFaceGroups_imageFaces } from './__generated__/myFaces' + +require('../../localization').setupLocalization() + +test('face circle image', () => { + const imageFace: myFaces_myFaceGroups_imageFaces = { + __typename: 'ImageFace', + id: '3', + media: { + id: '1', + __typename: 'Media', + thumbnail: { + __typename: 'MediaURL', + url: 'http://localhost:4001/photo/thumbnail_my_image_jpg_p9x8dLWr.jpg', + width: 1024, + height: 641, + }, + }, + rectangle: { + __typename: 'FaceRectangle', + minX: 0.27, + maxX: 0.34, + minY: 0.76, + maxY: 0.88, + }, + } + + render() + + expect(screen.getByRole('img')).toBeInTheDocument() + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + imageFace.media.thumbnail!.url + ) +}) diff --git a/ui/src/Pages/PeoplePage/FaceCircleImage.tsx b/ui/src/Pages/PeoplePage/FaceCircleImage.tsx new file mode 100644 index 00000000..d61d0575 --- /dev/null +++ b/ui/src/Pages/PeoplePage/FaceCircleImage.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import styled from 'styled-components' +import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia' +import { myFaces_myFaceGroups_imageFaces } from './__generated__/myFaces' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const FaceImage = styled(({ origin, selectable, scale, ...rest }) => ( + +))<{ origin: { x: number; y: number }; selectable: boolean; scale: number }>` + position: absolute; + transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`}; + object-fit: cover; + + transition: transform 250ms ease-out; +` + +const FaceImagePortrait = styled(FaceImage)` + width: 100%; + top: 50%; + transform: translateY(-50%) + ${({ origin, scale }) => + `translate(${(0.5 - origin.x) * 100}%, ${ + (0.5 - origin.y) * 100 + }%) scale(${Math.max(scale * 0.8, 1)})`}; + + ${({ selectable, origin, scale }) => + selectable + ? ` + &:hover { + transform: translateY(-50%) translate(${(0.5 - origin.x) * 100}%, ${ + (0.5 - origin.y) * 100 + }%) scale(${Math.max(scale * 0.85, 1)}) + ` + : ''} +` + +const FaceImageLandscape = styled(FaceImage)` + height: 100%; + left: 50%; + transform: translateX(-50%) + ${({ origin, scale }) => + `translate(${(0.5 - origin.x) * 100}%, ${ + (0.5 - origin.y) * 100 + }%) scale(${Math.max(scale * 0.8, 1)})`}; + + ${({ selectable, origin, scale }) => + selectable + ? ` + &:hover { + transform: translateX(-50%) translate(${(0.5 - origin.x) * 100}%, ${ + (0.5 - origin.y) * 100 + }%) scale(${Math.max(scale * 0.85, 1)}) + ` + : ''} +` + +const CircleImageWrapper = styled.div<{ size: string }>` + background-color: #eee; + position: relative; + border-radius: 50%; + width: ${({ size }) => size}; + height: ${({ size }) => size}; + object-fit: fill; + overflow: hidden; +` + +type FaceCircleImageProps = { + imageFace: myFaces_myFaceGroups_imageFaces + selectable: boolean + size?: string +} + +const FaceCircleImage = ({ + imageFace, + selectable, + size = '150px', +}: FaceCircleImageProps) => { + if (!imageFace) { + return null + } + + const rect = imageFace.rectangle + + const scale = Math.min( + 1 / (rect.maxX - rect.minX), + 1 / (rect.maxY - rect.minY) + ) + + const origin = { + x: (rect.minX + rect.maxX) / 2, + y: (rect.minY + rect.maxY) / 2, + } + + let SpecificFaceImage: typeof FaceImageLandscape | typeof FaceImagePortrait = + FaceImageLandscape + if (imageFace.media.thumbnail) { + SpecificFaceImage = + imageFace.media.thumbnail.width > imageFace.media.thumbnail.height + ? FaceImageLandscape + : FaceImagePortrait + } + + return ( + + + + ) +} + +export default FaceCircleImage diff --git a/ui/src/Pages/PeoplePage/PeoplePage.test.tsx b/ui/src/Pages/PeoplePage/PeoplePage.test.tsx index 0d9db98f..0df364ef 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.test.tsx +++ b/ui/src/Pages/PeoplePage/PeoplePage.test.tsx @@ -1,10 +1,15 @@ import '@testing-library/jest-dom' import React from 'react' -import { render, screen, waitFor } from '@testing-library/react' -import PeoplePage, { MY_FACES_QUERY } from './PeoplePage' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import PeoplePage, { + FaceDetails, + MY_FACES_QUERY, + SET_GROUP_LABEL_MUTATION, +} from './PeoplePage' import { MockedProvider } from '@apollo/client/testing' import { MemoryRouter } from 'react-router' +import { myFaces_myFaceGroups } from './__generated__/myFaces' require('../../localization').setupLocalization() @@ -15,88 +20,202 @@ jest.mock('../../hooks/useScrollPagination', () => })) ) -const graphqlMocks = [ - { - request: { - query: MY_FACES_QUERY, - variables: { - limit: 50, - offset: 0, +describe('PeoplePage component', () => { + const graphqlMocks = [ + { + request: { + query: MY_FACES_QUERY, + variables: { + limit: 50, + offset: 0, + }, }, - }, - result: { - data: { - myFaceGroups: [ - { - __typename: 'FaceGroup', - id: '3', - label: 'Person A', - imageFaceCount: 2, - imageFaces: [ - { - __typename: 'ImageFace', - id: '3', - rectangle: { - __typename: 'FaceRectangle', - minX: 0.2705079913139343, - maxX: 0.3408200144767761, - minY: 0.7691109776496887, - maxY: 0.881434977054596, - }, - media: { - __typename: 'Media', - id: '63', - thumbnail: { - __typename: 'MediaURL', - url: 'http://localhost:4001/photo/thumbnail_L%C3%B8berute_jpg_p9x8dLWr.jpg', - width: 1024, - height: 641, + result: { + data: { + myFaceGroups: [ + { + __typename: 'FaceGroup', + id: '3', + label: 'Person A', + imageFaceCount: 2, + imageFaces: [ + { + __typename: 'ImageFace', + id: '3', + rectangle: { + __typename: 'FaceRectangle', + minX: 0.2705079913139343, + maxX: 0.3408200144767761, + minY: 0.7691109776496887, + maxY: 0.881434977054596, + }, + media: { + __typename: 'Media', + id: '63', + thumbnail: { + __typename: 'MediaURL', + url: 'http://localhost:4001/photo/thumbnail_L%C3%B8berute_jpg_p9x8dLWr.jpg', + width: 1024, + height: 641, + }, }, }, - }, - ], - }, - { - __typename: 'FaceGroup', - id: '1', - label: 'Person B', - imageFaceCount: 1, - imageFaces: [], - }, - ], + ], + }, + { + __typename: 'FaceGroup', + id: '1', + label: 'Person B', + imageFaceCount: 1, + imageFaces: [], + }, + ], + }, }, }, - }, -] + ] -test('people page', async () => { - const matchMock = { - params: { - person: undefined, - }, + test('people page', async () => { + const matchMock = { + params: { + person: undefined, + }, + } + + render( + + + + + + ) + + expect(screen.getByTestId('Layout')).toBeInTheDocument() + expect(screen.getByText('Recognize unlabeled faces')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByText('Person A')).toBeInTheDocument() + expect(screen.getByText('Person B')).toBeInTheDocument() + }) + + expect( + screen + .getAllByRole('link') + .some(x => x.getAttribute('href') == '/people/1') + ).toBeTruthy() + + expect( + screen + .getAllByRole('link') + .some(x => x.getAttribute('href') == '/people/3') + ).toBeTruthy() + }) +}) + +describe('FaceDetails component', () => { + const faceGroup: myFaces_myFaceGroups = { + id: '3', + label: null, + imageFaceCount: 2, + imageFaces: [ + { + id: '3', + rectangle: { + minX: 0.2705079913139343, + maxX: 0.3408200144767761, + minY: 0.7691109776496887, + maxY: 0.881434977054596, + __typename: 'FaceRectangle', + }, + media: { + id: '63', + thumbnail: { + url: 'http://localhost:4001/photo/thumbnail_image_jpg_p9x8dLWr.jpg', + width: 1024, + height: 641, + __typename: 'MediaURL', + }, + __typename: 'Media', + }, + __typename: 'ImageFace', + }, + ], + __typename: 'FaceGroup', } - render( - - - + test('unlabeled, no images', () => { + const emptyFaceGroup: myFaces_myFaceGroups = { + ...faceGroup, + imageFaces: [], + } + + render( + + - - ) + ) - expect(screen.getByTestId('Layout')).toBeInTheDocument() - expect(screen.getByText('Recognize unlabeled faces')).toBeInTheDocument() - - await waitFor(() => { - expect(screen.getByText('Person A')).toBeInTheDocument() - expect(screen.getByText('Person B')).toBeInTheDocument() + expect(screen.getByText('Unlabeled')).toBeInTheDocument() }) - expect( - screen.getAllByRole('link').some(x => x.getAttribute('href') == '/people/1') - ).toBeTruthy() + test('labeled, with thumbnail', () => { + const labeledFaceGroup: myFaces_myFaceGroups = { + ...faceGroup, + label: 'Some label', + } - expect( - screen.getAllByRole('link').some(x => x.getAttribute('href') == '/people/3') - ).toBeTruthy() + render( + + + + ) + + expect(screen.getByText(labeledFaceGroup.label!)).toBeInTheDocument() + expect(screen.queryByText('Unlabeled')).not.toBeInTheDocument() + }) + + test('add label to face group', async () => { + const graphqlMocks = [ + { + request: { + query: SET_GROUP_LABEL_MUTATION, + variables: { + groupID: '3', + label: 'John Doe', + }, + }, + newData: jest.fn(() => ({ + data: { + setFaceGroupLabel: { + __typename: 'FaceGroup', + id: '3', + label: 'John Doe', + }, + }, + })), + }, + ] + render( + + + + ) + + const btn = screen.getByRole('button') + expect(btn).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + + fireEvent.click(btn) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('') + + fireEvent.change(input, { target: { value: 'John Doe' } }) + fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' }) + + await waitFor(() => { + expect(graphqlMocks[0].newData).toHaveBeenCalled() + }) + }) }) diff --git a/ui/src/Pages/PeoplePage/PeoplePage.tsx b/ui/src/Pages/PeoplePage/PeoplePage.tsx index 2a625cd2..2aaf0fbd 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.tsx +++ b/ui/src/Pages/PeoplePage/PeoplePage.tsx @@ -86,7 +86,7 @@ type FaceDetailsProps = { group: myFaces_myFaceGroups } -const FaceDetails = ({ group }: FaceDetailsProps) => { +export const FaceDetails = ({ group }: FaceDetailsProps) => { const { t } = useTranslation() const [editLabel, setEditLabel] = useState(false) const [inputValue, setInputValue] = useState(group.label ?? '') diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx new file mode 100644 index 00000000..f72a8389 --- /dev/null +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx @@ -0,0 +1,104 @@ +import { gql, useQuery } from '@apollo/client' +import React, { useEffect, useReducer } from 'react' +import PaginateLoader from '../../../components/PaginateLoader' +import PhotoGallery from '../../../components/photoGallery/PhotoGallery' +import { photoGalleryReducer } from '../../../components/photoGallery/photoGalleryReducer' +import useScrollPagination from '../../../hooks/useScrollPagination' +import FaceGroupTitle from './FaceGroupTitle' +import { + singleFaceGroup, + singleFaceGroupVariables, +} from './__generated__/singleFaceGroup' + +export const SINGLE_FACE_GROUP = gql` + query singleFaceGroup($id: ID!, $limit: Int!, $offset: Int!) { + faceGroup(id: $id) { + id + label + imageFaces(paginate: { limit: $limit, offset: $offset }) { + id + rectangle { + minX + maxX + minY + maxY + } + media { + id + type + title + thumbnail { + url + width + height + } + highRes { + url + } + favorite + } + } + } + } +` + +type SingleFaceGroupProps = { + faceGroupID: string +} + +const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => { + const { data, error, loading, fetchMore } = useQuery< + singleFaceGroup, + singleFaceGroupVariables + >(SINGLE_FACE_GROUP, { + variables: { + limit: 200, + offset: 0, + id: faceGroupID, + }, + }) + + const [mediaState, dispatchMedia] = useReducer(photoGalleryReducer, { + presenting: false, + activeIndex: -1, + media: [], + }) + + const { containerElem, finished: finishedLoadingMore } = + useScrollPagination({ + loading, + fetchMore, + data, + getItems: data => data.faceGroup.imageFaces, + }) + + useEffect(() => { + const media = data?.faceGroup.imageFaces.map(x => x.media) || [] + dispatchMedia({ type: 'replaceMedia', media }) + }, [data]) + + const faceGroup = data?.faceGroup + + if (error) { + return
{error.message}
+ } + + return ( +
+ +
+ + +
+
+ ) +} + +export default SingleFaceGroup