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