Merge pull request #372 from photoview/refactor-faces-page

Fix faces page and rewrite in Typescript
This commit is contained in:
Viktor Strate Kløvedal
2021-05-19 21:49:15 +02:00
committed by GitHub
24 changed files with 1364 additions and 475 deletions

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": "Tilføj navn",
"change_label": "Ændre navn",
"detach_face": "Løsriv ansigter",
"merge_face": "Sammenflet ansigter",
"move_faces": "Flyt ansigter"
},
"label_placeholder": "Navn",
"unlabeled": "Ikke navngivet"
"unlabeled": "Ikke navngivet",
"unlabeled_person": "Ikke navngivet person"
},
"recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter"
"modal": {
"action": {
"merge": "Sammenflet"
},
"detach_image_faces": {
"action": {
"detach": "Løsriv billeder",
"select_images": "Vælg billeder som skal løsrives"
},
"description": "Løsriv valgte billeder fra denne gruppe og flyt dem til sin egen gruppe",
"title": "Løsriv Billeder"
},
"merge_face_groups": {
"description": "Alle billeder fra denne gruppe vil blive flettet sammen med den valgte gruppe",
"destination_table": {
"title": "Vælg destinationsgruppe"
},
"title": "Vælg gruppe at flette med"
},
"move_image_faces": {
"description": "Flyt valgte billeder fra denne gruppe til en anden gruppe",
"destination_face_group_table": {
"move_action": "Flyt ansigter",
"title": "Vælg destinationsgruppe"
},
"image_select_table": {
"next_action": "Næste",
"title": "Vælg billeder som skal flyttes"
},
"title": "Flyt Ansigter"
}
},
"recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter",
"table": {
"select_face_group": {
"search_faces_placeholder": "Søg ansigter..."
},
"select_image_faces": {
"search_images_placeholder": "Søg billeder..."
}
}
},
"photos_page": {
"title": "Billeder"
@@ -156,9 +204,9 @@
"title": "Brugere"
},
"version_info": {
"build_date_title": null,
"title": null,
"version_title": null
"build_date_title": "Dato for udgivelse",
"title": "Photoview Version",
"version_title": "Udgivelsesversion"
}
},
"share_page": {
@@ -252,7 +300,7 @@
},
"title": {
"loading_album": "Loader album",
"login": null,
"login": "Logind",
"people": "Personer",
"settings": "Indstillinger"
}

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Zuordnung",
"unlabeled": "Nicht zugeordnet"
"unlabeled": "Nicht zugeordnet",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Nicht zugeordnete Gesichter erkennen"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Nicht zugeordnete Gesichter erkennen",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Fotos"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": "Add Label",
"change_label": "Change Label",
"detach_face": "Detach Face",
"merge_face": "Merge Face",
"move_faces": "Move Faces"
},
"label_placeholder": "Label",
"unlabeled": "Unlabeled"
"unlabeled": "Unlabeled",
"unlabeled_person": "Unlabeled person"
},
"recognize_unlabeled_faces_button": "Recognize unlabeled faces"
"modal": {
"action": {
"merge": "Merge"
},
"detach_image_faces": {
"action": {
"detach": "Detach image faces",
"select_images": "Select images to detach"
},
"description": "Detach selected images of this face group and move them to a new face groups",
"title": "Detach Image Faces"
},
"merge_face_groups": {
"description": "All images within this face group will be merged into the selected face group.",
"destination_table": {
"title": "Select the destination face"
},
"title": "Merge Face Groups"
},
"move_image_faces": {
"description": "Move selected images of this face group to another face group",
"destination_face_group_table": {
"move_action": "Move image faces",
"title": "Select destination face group"
},
"image_select_table": {
"next_action": "Next",
"title": "Select images to move"
},
"title": "Move Image Faces"
}
},
"recognize_unlabeled_faces_button": "Recognize unlabeled faces",
"table": {
"select_face_group": {
"search_faces_placeholder": "Search faces..."
},
"select_image_faces": {
"search_images_placeholder": "Search images..."
}
}
},
"photos_page": {
"title": "Photos"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Etiqueta",
"unlabeled": "Sin etiquetar"
"unlabeled": "Sin etiquetar",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Reconocer caras sin etiquetar"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Reconocer caras sin etiquetar",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Fotos"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Étiquette",
"unlabeled": "Sans étiquette"
"unlabeled": "Sans étiquette",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Reconnaître les visages sans étiquette"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Reconnaître les visages sans étiquette",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Photos"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Etichetta",
"unlabeled": "Senza etichetta"
"unlabeled": "Senza etichetta",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Identifica facce senza etichetta"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Identifica facce senza etichetta",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Foto"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Etykieta",
"unlabeled": "Nieoznakowany"
"unlabeled": "Nieoznakowany",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Rozpoznaj nieoznakowane twarze"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Rozpoznaj nieoznakowane twarze",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Zdjęcia"

View File

@@ -68,10 +68,58 @@
},
"people_page": {
"face_group": {
"action": {
"add_label": null,
"change_label": null,
"detach_face": null,
"merge_face": null,
"move_faces": null
},
"label_placeholder": "Märkning",
"unlabeled": "Omärkt"
"unlabeled": "Omärkt",
"unlabeled_person": null
},
"recognize_unlabeled_faces_button": "Känna igen omärkta ansikten"
"modal": {
"action": {
"merge": null
},
"detach_image_faces": {
"action": {
"detach": null,
"select_images": null
},
"description": null,
"title": null
},
"merge_face_groups": {
"description": null,
"destination_table": {
"title": null
},
"title": null
},
"move_image_faces": {
"description": null,
"destination_face_group_table": {
"move_action": null,
"title": null
},
"image_select_table": {
"next_action": null,
"title": null
},
"title": null
}
},
"recognize_unlabeled_faces_button": "Känna igen omärkta ansikten",
"table": {
"select_face_group": {
"search_faces_placeholder": null
},
"select_image_faces": {
"search_images_placeholder": null
}
}
},
"photos_page": {
"title": "Bilder"

View File

@@ -1,101 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
const FaceImage = styled(ProtectedImage)`
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`
background-color: #eee;
position: relative;
border-radius: 50%;
width: ${({ size }) => size};
height: ${({ size }) => size};
object-fit: fill;
overflow: hidden;
`
const FaceCircleImage = ({ imageFace, selectable, size = '150px' }) => {
if (!imageFace) {
return null
}
const rect = imageFace.rectangle
let scale = Math.min(1 / (rect.maxX - rect.minX), 1 / (rect.maxY - rect.minY))
let origin = {
x: (rect.minX + rect.maxX) / 2,
y: (rect.minY + rect.maxY) / 2,
}
const SpecificFaceImage =
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
? FaceImageLandscape
: FaceImagePortrait
return (
<CircleImageWrapper size={size}>
<SpecificFaceImage
$selectable={selectable}
$scale={scale}
$origin={origin}
src={imageFace.media.thumbnail.url}
/>
</CircleImageWrapper>
)
}
FaceCircleImage.propTypes = {
imageFace: PropTypes.object,
selectable: PropTypes.bool,
size: PropTypes.string,
}
export default FaceCircleImage

View File

@@ -0,0 +1,41 @@
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',
title: 'my_image.jpg',
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(<FaceCircleImage imageFace={imageFace} selectable={true} />)
expect(screen.getByRole('img')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
imageFace.media.thumbnail!.url
)
})

View File

@@ -0,0 +1,125 @@
import React from 'react'
import styled from 'styled-components'
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
import {
myFaces_myFaceGroups_imageFaces_media,
myFaces_myFaceGroups_imageFaces_rectangle,
} from './__generated__/myFaces'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FaceImage = styled(({ origin, selectable, scale, ...rest }) => (
<ProtectedImage {...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 FaceCircleImageFace = {
__typename: 'ImageFace'
id: string
rectangle: myFaces_myFaceGroups_imageFaces_rectangle
media: myFaces_myFaceGroups_imageFaces_media
}
type FaceCircleImageProps = {
imageFace: FaceCircleImageFace
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 (
<CircleImageWrapper size={size}>
<SpecificFaceImage
selectable={selectable}
scale={scale}
origin={origin}
src={imageFace.media.thumbnail?.url}
/>
</CircleImageWrapper>
)
}
export default FaceCircleImage

View File

@@ -0,0 +1,223 @@
import '@testing-library/jest-dom'
import React from 'react'
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()
jest.mock('../../hooks/useScrollPagination', () =>
jest.fn(() => ({
finished: true,
containerElem: jest.fn(),
}))
)
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',
title: 'image.jpg',
thumbnail: {
__typename: 'MediaURL',
url: 'http://localhost:4001/photo/thumbnail_image_jpg_p9x8dLWr.jpg',
width: 1024,
height: 641,
},
},
},
],
},
{
__typename: 'FaceGroup',
id: '1',
label: 'Person B',
imageFaceCount: 1,
imageFaces: [],
},
],
},
},
},
]
test('people page', async () => {
const matchMock = {
params: {
person: undefined,
},
}
render(
<MemoryRouter initialEntries={['/people']}>
<MockedProvider mocks={graphqlMocks} addTypename={false}>
<PeoplePage match={matchMock} />
</MockedProvider>
</MemoryRouter>
)
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',
title: 'image.jpg',
thumbnail: {
url: 'http://localhost:4001/photo/thumbnail_image_jpg_p9x8dLWr.jpg',
width: 1024,
height: 641,
__typename: 'MediaURL',
},
__typename: 'Media',
},
__typename: 'ImageFace',
},
],
__typename: 'FaceGroup',
}
test('unlabeled, no images', () => {
const emptyFaceGroup: myFaces_myFaceGroups = {
...faceGroup,
imageFaces: [],
}
render(
<MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={emptyFaceGroup} />
</MockedProvider>
)
expect(screen.getByText('Unlabeled')).toBeInTheDocument()
})
test('labeled, with thumbnail', () => {
const labeledFaceGroup: myFaces_myFaceGroups = {
...faceGroup,
label: 'Some label',
}
render(
<MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={labeledFaceGroup} />
</MockedProvider>
)
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(
<MockedProvider mocks={graphqlMocks} addTypename={false}>
<FaceDetails group={faceGroup} />
</MockedProvider>
)
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()
})
})
})

View File

@@ -1,5 +1,4 @@
import React, { createRef, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { gql, useMutation, useQuery } from '@apollo/client'
import Layout from '../../Layout'
import styled from 'styled-components'
@@ -10,6 +9,16 @@ import FaceCircleImage from './FaceCircleImage'
import useScrollPagination from '../../hooks/useScrollPagination'
import PaginateLoader from '../../components/PaginateLoader'
import { useTranslation } from 'react-i18next'
import {
setGroupLabel,
setGroupLabelVariables,
} from './__generated__/setGroupLabel'
import {
myFaces,
myFacesVariables,
myFaces_myFaceGroups,
} from './__generated__/myFaces'
import { recognizeUnlabeledFaces } from './__generated__/recognizeUnlabeledFaces'
export const MY_FACES_QUERY = gql`
query myFaces($limit: Int, $offset: Int) {
@@ -27,6 +36,7 @@ export const MY_FACES_QUERY = gql`
}
media {
id
title
thumbnail {
url
width
@@ -55,7 +65,7 @@ const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
}
`
const FaceDetailsButton = styled.button`
const FaceDetailsButton = styled.button<{ labeled: boolean }>`
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
width: 150px;
margin: 12px auto 24px;
@@ -73,13 +83,20 @@ const FaceDetailsButton = styled.button`
const FaceLabel = styled.span``
const FaceDetails = ({ group }) => {
type FaceDetailsProps = {
group: myFaces_myFaceGroups
}
export const FaceDetails = ({ group }: FaceDetailsProps) => {
const { t } = useTranslation()
const [editLabel, setEditLabel] = useState(false)
const [inputValue, setInputValue] = useState(group.label ?? '')
const inputRef = createRef()
const inputRef = createRef<Input>()
const [setGroupLabel, { loading }] = useMutation(SET_GROUP_LABEL_MUTATION, {
const [setGroupLabel, { loading }] = useMutation<
setGroupLabel,
setGroupLabelVariables
>(SET_GROUP_LABEL_MUTATION, {
variables: {
groupID: group.id,
},
@@ -91,9 +108,7 @@ const FaceDetails = ({ group }) => {
}
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
inputRef.current?.focus()
}, [inputRef])
useEffect(() => {
@@ -102,7 +117,7 @@ const FaceDetails = ({ group }) => {
}
}, [loading])
const onKeyUp = e => {
const onKeyUp = (e: React.ChangeEvent<HTMLInputElement> & KeyboardEvent) => {
if (e.key == 'Escape') {
resetLabel()
return
@@ -111,6 +126,7 @@ const FaceDetails = ({ group }) => {
if (e.key == 'Enter') {
setGroupLabel({
variables: {
groupID: group.id,
label: e.target.value == '' ? null : e.target.value,
},
})
@@ -155,10 +171,6 @@ const FaceDetails = ({ group }) => {
return label
}
FaceDetails.propTypes = {
group: PropTypes.object.isRequired,
}
const FaceImagesCount = styled.span`
background-color: #eee;
color: #222;
@@ -179,7 +191,11 @@ const EditIcon = styled(Icon)`
}
`
const FaceGroup = ({ group }) => {
type FaceGroupProps = {
group: myFaces_myFaceGroups
}
const FaceGroup = ({ group }: FaceGroupProps) => {
const previewFace = group.imageFaces[0]
return (
@@ -192,10 +208,6 @@ const FaceGroup = ({ group }) => {
)
}
FaceGroup.propTypes = {
group: PropTypes.any,
}
const FaceGroupsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
@@ -204,27 +216,29 @@ const FaceGroupsWrapper = styled.div`
const PeopleGallery = () => {
const { t } = useTranslation()
const { data, error, loading, fetchMore } = useQuery(MY_FACES_QUERY, {
const { data, error, loading, fetchMore } = useQuery<
myFaces,
myFacesVariables
>(MY_FACES_QUERY, {
variables: {
limit: 50,
offset: 0,
},
})
const [
recognizeUnlabeled,
{ loading: recognizeUnlabeledLoading },
] = useMutation(RECOGNIZE_UNLABELED_FACES_MUTATION)
const [recognizeUnlabeled, { loading: recognizeUnlabeledLoading }] =
useMutation<recognizeUnlabeledFaces>(RECOGNIZE_UNLABELED_FACES_MUTATION)
const { containerElem, finished: finishedLoadingMore } = useScrollPagination({
loading,
fetchMore,
data,
getItems: data => data.myFaceGroups,
})
const { containerElem, finished: finishedLoadingMore } =
useScrollPagination<myFaces>({
loading,
fetchMore,
data,
getItems: data => data.myFaceGroups,
})
if (error) {
return error.message
return <div>{error.message}</div>
}
let faces = null
@@ -258,11 +272,20 @@ const PeopleGallery = () => {
)
}
const PeoplePage = ({ match }) => {
type PeoplePageProps = {
match: {
params: {
person?: string
}
}
}
const PeoplePage = ({ match }: PeoplePageProps) => {
const { t } = useTranslation()
const faceGroup = match.params.person
if (faceGroup) {
return (
<Layout>
<Layout title={t('title.people', 'People')}>
<SingleFaceGroup faceGroupID={faceGroup} />
</Layout>
)
@@ -271,8 +294,4 @@ const PeoplePage = ({ match }) => {
}
}
PeoplePage.propTypes = {
match: PropTypes.object.isRequired,
}
export default PeoplePage

View File

@@ -1,10 +1,23 @@
import { gql, useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import { isNil } from '../../../helpers/utils'
import { MY_FACES_QUERY } from '../PeoplePage'
import {
myFaces_myFaceGroups,
myFaces_myFaceGroups_imageFaces,
} from '../__generated__/myFaces'
import SelectImageFacesTable from './SelectImageFacesTable'
import {
detachImageFaces,
detachImageFacesVariables,
} from './__generated__/detachImageFaces'
import {
singleFaceGroup_faceGroup,
singleFaceGroup_faceGroup_imageFaces,
} from './__generated__/singleFaceGroup'
const DETACH_IMAGE_FACES_MUTATION = gql`
mutation detachImageFaces($faceIDs: [ID!]!) {
@@ -15,12 +28,28 @@ const DETACH_IMAGE_FACES_MUTATION = gql`
}
`
const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
const [selectedImageFaces, setSelectedImageFaces] = useState([])
let history = useHistory()
type DetachImageFacesModalProps = {
open: boolean
setOpen(open: boolean): void
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
}
const [detachImageFacesMutation] = useMutation(DETACH_IMAGE_FACES_MUTATION, {
variables: {},
const DetachImageFacesModal = ({
open,
setOpen,
faceGroup,
}: DetachImageFacesModalProps) => {
const { t } = useTranslation()
const [selectedImageFaces, setSelectedImageFaces] = useState<
(myFaces_myFaceGroups_imageFaces | singleFaceGroup_faceGroup_imageFaces)[]
>([])
const history = useHistory()
const [detachImageFacesMutation] = useMutation<
detachImageFaces,
detachImageFacesVariables
>(DETACH_IMAGE_FACES_MUTATION, {
refetchQueries: [
{
query: MY_FACES_QUERY,
@@ -44,6 +73,7 @@ const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
faceIDs,
},
}).then(({ data }) => {
if (isNil(data)) throw new Error('Expected data not to be null')
setOpen(false)
history.push(`/people/${data.detachImageFaces.id}`)
})
@@ -57,18 +87,25 @@ const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Detach Image Faces</Modal.Header>
<Modal.Header>
{t('people_page.modal.detach_image_faces.title', 'Detach Image Faces')}
</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
Detach selected images of this face group and move them to a new
face group
{t(
'people_page.modal.detach_image_faces.description',
'Detach selected images of this face group and move them to a new face groups'
)}
</p>
<SelectImageFacesTable
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title="Select images to detach"
title={t(
'people_page.modal.detach_image_faces.action.select_images',
'Select images to detach'
)}
/>
</Modal.Description>
</Modal.Content>
@@ -76,7 +113,10 @@ const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedImageFaces.length == 0}
content="Detach image faces"
content={t(
'people_page.modal.detach_image_faces.action.detach',
'Detach image faces'
)}
labelPosition="right"
icon="checkmark"
onClick={() => detachImageFaces()}
@@ -87,10 +127,4 @@ const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
)
}
DetachImageFacesModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
faceGroup: PropTypes.object,
}
export default DetachImageFacesModal

View File

@@ -1,18 +1,24 @@
import { useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState, useEffect, createRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown, Input } from 'semantic-ui-react'
import styled from 'styled-components'
import { isNil } from '../../../helpers/utils'
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
import {
setGroupLabel,
setGroupLabelVariables,
} from '../__generated__/setGroupLabel'
import DetachImageFacesModal from './DetachImageFacesModal'
import MergeFaceGroupsModal from './MergeFaceGroupsModal'
import MoveImageFacesModal from './MoveImageFacesModal'
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
const TitleWrapper = styled.div`
min-height: 3.5em;
`
const TitleLabel = styled.h1`
const TitleLabel = styled.h1<{ labeled: boolean }>`
display: inline-block;
color: ${({ labeled }) => (labeled ? 'black' : '#888')};
margin-right: 12px;
@@ -28,22 +34,24 @@ const TitleDropdown = styled(Dropdown)`
}
`
const FaceGroupTitle = ({ faceGroup }) => {
type FaceGroupTitleProps = {
faceGroup?: singleFaceGroup_faceGroup
}
const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
const { t } = useTranslation()
const [editLabel, setEditLabel] = useState(false)
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
const inputRef = createRef()
const inputRef = createRef<Input>()
const [mergeModalOpen, setMergeModalOpen] = useState(false)
const [moveModalOpen, setMoveModalOpen] = useState(false)
const [detachModalOpen, setDetachModalOpen] = useState(false)
const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
SET_GROUP_LABEL_MUTATION,
{
variables: {
groupID: faceGroup?.id,
},
}
)
const [setGroupLabel, { loading: setLabelLoading }] = useMutation<
setGroupLabel,
setGroupLabelVariables
>(SET_GROUP_LABEL_MUTATION)
const resetLabel = () => {
setInputValue(faceGroup?.label ?? '')
@@ -62,7 +70,9 @@ const FaceGroupTitle = ({ faceGroup }) => {
}
}, [setLabelLoading])
const onKeyUp = e => {
const onKeyUp = (e: KeyboardEvent & React.ChangeEvent<HTMLInputElement>) => {
if (isNil(faceGroup)) throw new Error('Expected faceGroup to be defined')
if (e.key == 'Escape') {
resetLabel()
return
@@ -71,6 +81,7 @@ const FaceGroupTitle = ({ faceGroup }) => {
if (e.key == 'Enter') {
setGroupLabel({
variables: {
groupID: faceGroup.id,
label: e.target.value == '' ? null : e.target.value,
},
})
@@ -83,7 +94,8 @@ const FaceGroupTitle = ({ faceGroup }) => {
title = (
<TitleWrapper>
<TitleLabel labeled={!!faceGroup?.label}>
{faceGroup?.label ?? 'Unlabeled person'}
{faceGroup?.label ??
t('people_page.face_group.unlabeled_person', 'Unlabeled person')}
</TitleLabel>
<TitleDropdown
icon={{
@@ -94,22 +106,32 @@ const FaceGroupTitle = ({ faceGroup }) => {
<Dropdown.Menu>
<Dropdown.Item
icon="pencil"
text={faceGroup?.label ? 'Change Label' : 'Add Label'}
text={
faceGroup?.label
? t(
'people_page.face_group.action.change_label',
'Change Label'
)
: t('people_page.face_group.action.add_label', 'Add Label')
}
onClick={() => setEditLabel(true)}
/>
<Dropdown.Item
icon="object group"
text="Merge Face"
text={t('people_page.face_group.action.merge_face', 'Merge Face')}
onClick={() => setMergeModalOpen(true)}
/>
<Dropdown.Item
icon="object ungroup"
text="Detach Faces"
text={t(
'people_page.face_group.action.detach_face',
'Detach Face'
)}
onClick={() => setDetachModalOpen(true)}
/>
<Dropdown.Item
icon="clone"
text="Move Faces"
text={t('people_page.face_group.action.move_faces', 'Move Faces')}
onClick={() => setMoveModalOpen(true)}
/>
</Dropdown.Menu>
@@ -122,7 +144,7 @@ const FaceGroupTitle = ({ faceGroup }) => {
<Input
loading={setLabelLoading}
ref={inputRef}
placeholder="Label"
placeholder={t('people_page.face_group.label_placeholder', 'Label')}
icon="arrow right"
value={inputValue}
onKeyUp={onKeyUp}
@@ -135,30 +157,35 @@ const FaceGroupTitle = ({ faceGroup }) => {
)
}
let modals = null
if (faceGroup) {
modals = (
<>
<MergeFaceGroupsModal
open={mergeModalOpen}
setOpen={setMergeModalOpen}
sourceFaceGroup={faceGroup}
/>
<MoveImageFacesModal
open={moveModalOpen}
setOpen={setMoveModalOpen}
faceGroup={faceGroup}
/>
<DetachImageFacesModal
open={detachModalOpen}
setOpen={setDetachModalOpen}
faceGroup={faceGroup}
/>
</>
)
}
return (
<>
{title}
<MergeFaceGroupsModal
open={mergeModalOpen}
setOpen={setMergeModalOpen}
sourceFaceGroup={faceGroup}
/>
<MoveImageFacesModal
open={moveModalOpen}
setOpen={setMoveModalOpen}
faceGroup={faceGroup}
/>
<DetachImageFacesModal
open={detachModalOpen}
setOpen={setDetachModalOpen}
faceGroup={faceGroup}
/>
{modals}
</>
)
}
FaceGroupTitle.propTypes = {
faceGroup: PropTypes.object,
}
export default FaceGroupTitle

View File

@@ -1,94 +0,0 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import { MY_FACES_QUERY } from '../PeoplePage'
import SelectFaceGroupTable from './SelectFaceGroupTable'
const COMBINE_FACES_MUTATION = gql`
mutation combineFaces($destID: ID!, $srcID: ID!) {
combineFaceGroups(
destinationFaceGroupID: $destID
sourceFaceGroupID: $srcID
) {
id
}
}
`
const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
let history = useHistory()
const { data } = useQuery(MY_FACES_QUERY)
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
variables: {
srcID: sourceFaceGroup?.id,
},
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
if (open == false) return null
const filteredFaceGroups =
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
const mergeFaceGroups = () => {
combineFacesMutation({
variables: {
destID: selectedFaceGroup.id,
},
}).then(() => {
setOpen(false)
history.push(`/people/${selectedFaceGroup.id}`)
})
}
return (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Merge Face Groups</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
All images within this face group will be merged into the selected
face group.
</p>
<SelectFaceGroupTable
title="Select the destination face"
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedFaceGroup == null}
content="Merge"
labelPosition="right"
icon="checkmark"
onClick={() => mergeFaceGroups()}
positive
/>
</Modal.Actions>
</Modal>
)
}
MergeFaceGroupsModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
sourceFaceGroup: PropTypes.object,
}
export default MergeFaceGroupsModal

View File

@@ -0,0 +1,124 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import { isNil } from '../../../helpers/utils'
import { MY_FACES_QUERY } from '../PeoplePage'
import {
myFaces,
myFacesVariables,
myFaces_myFaceGroups,
} from '../__generated__/myFaces'
import SelectFaceGroupTable from './SelectFaceGroupTable'
import {
combineFaces,
combineFacesVariables,
} from './__generated__/combineFaces'
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
const COMBINE_FACES_MUTATION = gql`
mutation combineFaces($destID: ID!, $srcID: ID!) {
combineFaceGroups(
destinationFaceGroupID: $destID
sourceFaceGroupID: $srcID
) {
id
}
}
`
type MergeFaceGroupsModalProps = {
open: boolean
setOpen(open: boolean): void
sourceFaceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
}
const MergeFaceGroupsModal = ({
open,
setOpen,
sourceFaceGroup,
}: MergeFaceGroupsModalProps) => {
const { t } = useTranslation()
const [selectedFaceGroup, setSelectedFaceGroup] =
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
const history = useHistory()
const { data } = useQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
const [combineFacesMutation] = useMutation<
combineFaces,
combineFacesVariables
>(COMBINE_FACES_MUTATION, {
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
if (open == false) return null
const filteredFaceGroups =
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
const mergeFaceGroups = () => {
if (isNil(selectedFaceGroup)) throw new Error('No selected face group')
combineFacesMutation({
variables: {
srcID: sourceFaceGroup.id,
destID: selectedFaceGroup.id,
},
}).then(() => {
setOpen(false)
history.push(`/people/${selectedFaceGroup.id}`)
})
}
return (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>
{t('people_page.modal.merge_face_groups.title', 'Merge Face Groups')}
</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
{t(
'people_page.modal.merge_face_groups.description',
'All images within this face group will be merged into the selected face group.'
)}
</p>
<SelectFaceGroupTable
title={t(
'people_page.modal.merge_face_groups.destination_table.title',
'Select the destination face'
)}
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>
{t('general.action.cancel', 'Cancel')}
</Button>
<Button
disabled={selectedFaceGroup == null}
content={t('people_page.modal.action.merge', 'Merge')}
labelPosition="right"
icon="checkmark"
onClick={() => mergeFaceGroups()}
positive
/>
</Modal.Actions>
</Modal>
)
}
export default MergeFaceGroupsModal

View File

@@ -1,11 +1,26 @@
import { gql, useLazyQuery, useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import SelectFaceGroupTable from './SelectFaceGroupTable'
import SelectImageFacesTable from './SelectImageFacesTable'
import { MY_FACES_QUERY } from '../PeoplePage'
import {
singleFaceGroup_faceGroup,
singleFaceGroup_faceGroup_imageFaces,
} from './__generated__/singleFaceGroup'
import {
myFaces,
myFacesVariables,
myFaces_myFaceGroups,
myFaces_myFaceGroups_imageFaces,
} from '../__generated__/myFaces'
import { isNil } from '../../../helpers/utils'
import {
moveImageFaces,
moveImageFacesVariables,
} from './__generated__/moveImageFaces'
import { useTranslation } from 'react-i18next'
const MOVE_IMAGE_FACES_MUTATION = gql`
mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
@@ -21,14 +36,31 @@ const MOVE_IMAGE_FACES_MUTATION = gql`
}
`
const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
const [selectedImageFaces, setSelectedImageFaces] = useState([])
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
const [imagesSelected, setImagesSelected] = useState(false)
let history = useHistory()
type MoveImageFacesModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
faceGroup: singleFaceGroup_faceGroup
}
const [moveImageFacesMutation] = useMutation(MOVE_IMAGE_FACES_MUTATION, {
variables: {},
const MoveImageFacesModal = ({
open,
setOpen,
faceGroup,
}: MoveImageFacesModalProps) => {
const { t } = useTranslation()
const [selectedImageFaces, setSelectedImageFaces] = useState<
(singleFaceGroup_faceGroup_imageFaces | myFaces_myFaceGroups_imageFaces)[]
>([])
const [selectedFaceGroup, setSelectedFaceGroup] =
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
const [imagesSelected, setImagesSelected] = useState(false)
const history = useHistory()
const [moveImageFacesMutation] = useMutation<
moveImageFaces,
moveImageFacesVariables
>(MOVE_IMAGE_FACES_MUTATION, {
refetchQueries: [
{
query: MY_FACES_QUERY,
@@ -36,9 +68,8 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
],
})
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery(
MY_FACES_QUERY
)
const [loadFaceGroups, { data: faceGroupsData }] =
useLazyQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
useEffect(() => {
if (imagesSelected) {
@@ -59,6 +90,10 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
const moveImageFaces = () => {
const faceIDs = selectedImageFaces.map(face => face.id)
if (isNil(selectedFaceGroup)) {
throw new Error('Expected selectedFaceGroup not to be null')
}
moveImageFacesMutation({
variables: {
faceIDs,
@@ -70,7 +105,7 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
})
}
const imageFaces = faceGroup?.imageFaces ?? []
const imageFaces = faceGroup.imageFaces
let table = null
if (!imagesSelected) {
@@ -79,24 +114,30 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title="Select images to move"
title={t(
'people_page.modal.move_image_faces.image_select_table.title',
'Select images to move'
)}
/>
)
} else {
if (faceGroupsData) {
if (faceGroupsData && faceGroup) {
const filteredFaceGroups = faceGroupsData.myFaceGroups.filter(
x => x != faceGroup
x => x.id != faceGroup.id
)
table = (
<SelectFaceGroupTable
title="Select destination face group"
title={t(
'people_page.modal.move_image_faces.destination_face_group_table.title',
'Select destination face group'
)}
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
)
} else {
table = <div>Loading...</div>
table = <div>{t('general.loading.default', 'Loading...')}</div>
}
}
@@ -105,7 +146,10 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
positiveButton = (
<Button
disabled={selectedImageFaces.length == 0}
content="Next"
content={t(
'people_page.modal.move_image_faces.image_select_table.next_action',
'Next'
)}
labelPosition="right"
icon="arrow right"
onClick={() => setImagesSelected(true)}
@@ -116,7 +160,10 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
positiveButton = (
<Button
disabled={!selectedFaceGroup}
content="Move image faces"
content={t(
'people_page.modal.move_image_faces.destination_face_group_table.move_action',
'Move image faces'
)}
labelPosition="right"
icon="checkmark"
onClick={() => moveImageFaces()}
@@ -131,25 +178,28 @@ const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Move Image Faces</Modal.Header>
<Modal.Header>
{t('people_page.modal.move_image_faces.title', 'Move Image Faces')}
</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>Move selected images of this face group to another face group</p>
<p>
{t(
'people_page.modal.move_image_faces.description',
'Move selected images of this face group to another face group'
)}
</p>
{table}
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => setOpen(false)}>
{t('general.action.cancel', 'Cancel')}
</Button>
{positiveButton}
</Modal.Actions>
</Modal>
)
}
MoveImageFacesModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
faceGroup: PropTypes.object,
}
export default MoveImageFacesModal

View File

@@ -1,10 +1,12 @@
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Input, Pagination, Table } from 'semantic-ui-react'
import styled from 'styled-components'
import FaceCircleImage from '../FaceCircleImage'
import { myFaces_myFaceGroups } from '../__generated__/myFaces'
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
const FaceCircleWrapper = styled.div`
const FaceCircleWrapper = styled.div<{ $selected: boolean }>`
display: inline-block;
border-radius: 50%;
border: 2px solid
@@ -16,17 +18,31 @@ const FlexCell = styled(Table.Cell)`
align-items: center;
`
export const RowLabel = styled.span`
export const RowLabel = styled.span<{ $selected: boolean }>`
${({ $selected }) => $selected && `font-weight: bold;`}
margin-left: 12px;
`
const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
type FaceGroupRowProps = {
faceGroup: myFaces_myFaceGroups
faceSelected: boolean
setFaceSelected(): void
}
const FaceGroupRow = ({
faceGroup,
faceSelected,
setFaceSelected,
}: FaceGroupRowProps) => {
return (
<Table.Row key={faceGroup.id} onClick={setFaceSelected}>
<FlexCell>
<FaceCircleWrapper $selected={faceSelected}>
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
<FaceCircleImage
imageFace={faceGroup.imageFaces[0]}
size="50px"
selectable={false}
/>
</FaceCircleWrapper>
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
</FlexCell>
@@ -34,10 +50,15 @@ const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
)
}
FaceGroupRow.propTypes = {
faceGroup: PropTypes.object.isRequired,
faceSelected: PropTypes.bool.isRequired,
setFaceSelected: PropTypes.func.isRequired,
type SelectFaceGroupTableProps = {
faceGroups: myFaces_myFaceGroups[]
selectedFaceGroup: singleFaceGroup_faceGroup | myFaces_myFaceGroups | null
setSelectedFaceGroup: React.Dispatch<
React.SetStateAction<
singleFaceGroup_faceGroup | myFaces_myFaceGroups | null
>
>
title: string
}
const SelectFaceGroupTable = ({
@@ -45,7 +66,9 @@ const SelectFaceGroupTable = ({
selectedFaceGroup,
setSelectedFaceGroup,
title,
}) => {
}: SelectFaceGroupTableProps) => {
const { t } = useTranslation()
const PAGE_SIZE = 6
const [page, setPage] = useState(0)
@@ -65,7 +88,7 @@ const SelectFaceGroupTable = ({
<FaceGroupRow
key={face.id}
faceGroup={face}
faceSelected={selectedFaceGroup == face}
faceSelected={selectedFaceGroup?.id == face.id}
setFaceSelected={() => setSelectedFaceGroup(face)}
/>
))
@@ -86,7 +109,10 @@ const SelectFaceGroupTable = ({
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder="Search faces..."
placeholder={t(
'people_page.table.select_face_group.search_faces_placeholder',
'Search faces...'
)}
fluid
/>
</Table.HeaderCell>
@@ -105,7 +131,11 @@ const SelectFaceGroupTable = ({
activePage={page + 1}
totalPages={Math.ceil(rows.length / PAGE_SIZE)}
onPageChange={(_, { activePage }) => {
setPage(Math.ceil(activePage) - 1)
if (activePage) {
setPage(Math.ceil(activePage as number) - 1)
} else {
setPage(0)
}
}}
/>
</Table.HeaderCell>
@@ -115,11 +145,4 @@ const SelectFaceGroupTable = ({
)
}
SelectFaceGroupTable.propTypes = {
faceGroups: PropTypes.array,
selectedFaceGroup: PropTypes.object,
setSelectedFaceGroup: PropTypes.func.isRequired,
title: PropTypes.string,
}
export default SelectFaceGroupTable

View File

@@ -1,16 +1,30 @@
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Checkbox, Input, Pagination, Table } from 'semantic-ui-react'
import styled from 'styled-components'
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
import { myFaces_myFaceGroups_imageFaces } from '../__generated__/myFaces'
import { RowLabel } from './SelectFaceGroupTable'
import { singleFaceGroup_faceGroup_imageFaces } from './__generated__/singleFaceGroup'
const SelectImagePreview = styled(ProtectedImage)`
max-width: 120px;
max-height: 80px;
`
const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
type ImageFaceRowProps = {
imageFace:
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
faceSelected: boolean
setFaceSelected(): void
}
const ImageFaceRow = ({
imageFace,
faceSelected,
setFaceSelected,
}: ImageFaceRowProps) => {
return (
<Table.Row key={imageFace.id}>
<Table.Cell>
@@ -18,7 +32,7 @@ const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
</Table.Cell>
<Table.Cell>
<SelectImagePreview
src={imageFace.media.thumbnail.url}
src={imageFace.media.thumbnail?.url}
onClick={setFaceSelected}
/>
</Table.Cell>
@@ -31,10 +45,21 @@ const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
)
}
ImageFaceRow.propTypes = {
imageFace: PropTypes.object.isRequired,
faceSelected: PropTypes.bool.isRequired,
setFaceSelected: PropTypes.func.isRequired,
type SelectImageFacesTable = {
imageFaces: (
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
)[]
selectedImageFaces: (
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
)[]
setSelectedImageFaces: React.Dispatch<
React.SetStateAction<
(myFaces_myFaceGroups_imageFaces | singleFaceGroup_faceGroup_imageFaces)[]
>
>
title: string
}
const SelectImageFacesTable = ({
@@ -42,7 +67,9 @@ const SelectImageFacesTable = ({
selectedImageFaces,
setSelectedImageFaces,
title,
}) => {
}: SelectImageFacesTable) => {
const { t } = useTranslation()
const PAGE_SIZE = 6
const [page, setPage] = useState(0)
@@ -91,7 +118,10 @@ const SelectImageFacesTable = ({
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder="Search images..."
placeholder={t(
'people_page.table.select_image_faces.search_images_placeholder',
'Search images...'
)}
fluid
/>
</Table.HeaderCell>
@@ -110,7 +140,11 @@ const SelectImageFacesTable = ({
activePage={page + 1}
totalPages={Math.ceil(rows.length / PAGE_SIZE)}
onPageChange={(_, { activePage }) => {
setPage(Math.ceil(activePage) - 1)
if (activePage) {
setPage(Math.ceil(activePage as number) - 1)
} else {
setPage(0)
}
}}
/>
</Table.HeaderCell>
@@ -120,11 +154,4 @@ const SelectImageFacesTable = ({
)
}
SelectImageFacesTable.propTypes = {
imageFaces: PropTypes.array,
selectedImageFaces: PropTypes.array,
setSelectedImageFaces: PropTypes.func.isRequired,
title: PropTypes.string,
}
export default SelectImageFacesTable

View File

@@ -1,10 +1,15 @@
import { gql, useQuery } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import React, { useEffect, useReducer } from 'react'
import { useTranslation } from 'react-i18next'
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!) {
@@ -38,69 +43,65 @@ export const SINGLE_FACE_GROUP = gql`
}
`
const SingleFaceGroup = ({ faceGroupID }) => {
const { data, error, loading, fetchMore } = useQuery(SINGLE_FACE_GROUP, {
type SingleFaceGroupProps = {
faceGroupID: string
}
const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => {
const { t } = useTranslation()
const { data, error, loading, fetchMore } = useQuery<
singleFaceGroup,
singleFaceGroupVariables
>(SINGLE_FACE_GROUP, {
variables: {
limit: 200,
offset: 0,
id: faceGroupID,
},
})
const [presenting, setPresenting] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const { containerElem, finished: finishedLoadingMore } = useScrollPagination({
loading,
fetchMore,
data,
getItems: data => data.faceGroup.imageFaces,
const [mediaState, dispatchMedia] = useReducer(photoGalleryReducer, {
presenting: false,
activeIndex: -1,
media: [],
})
const { containerElem, finished: finishedLoadingMore } =
useScrollPagination<singleFaceGroup>({
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 <div>{error.message}</div>
}
let mediaGallery = null
if (faceGroup) {
const media = faceGroup.imageFaces.map(x => x.media)
const nextImage = () =>
setActiveIndex(i => Math.min(i + 1, media.length - 1))
const previousImage = () => setActiveIndex(i => Math.max(i - 1, 0))
mediaGallery = (
<div>
<PhotoGallery
media={media}
loading={false}
presenting={presenting}
setPresenting={setPresenting}
onSelectImage={setActiveIndex}
activeIndex={activeIndex}
nextImage={nextImage}
previousImage={previousImage}
/>
<PaginateLoader
active={!finishedLoadingMore && !loading}
text="Loading more photos"
/>
</div>
)
}
return (
<div ref={containerElem}>
<FaceGroupTitle faceGroup={faceGroup} />
{mediaGallery}
<div>
<PhotoGallery
loading={loading}
dispatchMedia={dispatchMedia}
mediaState={mediaState}
/>
<PaginateLoader
active={!finishedLoadingMore && !loading}
text={t('general.loading.paginate.media', 'Loading more media')}
/>
</div>
</div>
)
}
SingleFaceGroup.propTypes = {
faceGroupID: PropTypes.string,
}
export default SingleFaceGroup

View File

@@ -34,6 +34,7 @@ export interface myFaces_myFaceGroups_imageFaces_media_thumbnail {
export interface myFaces_myFaceGroups_imageFaces_media {
__typename: 'Media'
id: string
title: string
/**
* URL to display the media in a smaller resolution
*/

View File

@@ -110,7 +110,7 @@ describe('load correct share page, based on graphql query', () => {
expect(screen.getByText('Loading...')).toBeInTheDocument()
await waitForElementToBeRemoved(() => screen.getByText('Loading...'))
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))
expect(screen.getByTestId('Layout')).toBeInTheDocument()
expect(screen.getByTestId('MediaSharePage')).toBeInTheDocument()

View File

@@ -3,178 +3,181 @@
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from "./../../../../__generated__/globalTypes";
import {
OrderDirection,
MediaType,
} from './../../../../__generated__/globalTypes'
// ====================================================
// GraphQL query operation: shareAlbumQuery
// ====================================================
export interface shareAlbumQuery_album_subAlbums_thumbnail_thumbnail {
__typename: "MediaURL";
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string;
url: string
}
export interface shareAlbumQuery_album_subAlbums_thumbnail {
__typename: "Media";
__typename: 'Media'
/**
* URL to display the media in a smaller resolution
*/
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail_thumbnail | null;
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail_thumbnail | null
}
export interface shareAlbumQuery_album_subAlbums {
__typename: "Album";
id: string;
title: string;
__typename: 'Album'
id: string
title: string
/**
* An image in this album used for previewing this album
*/
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail | null;
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail | null
}
export interface shareAlbumQuery_album_media_thumbnail {
__typename: "MediaURL";
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string;
url: string
/**
* Width of the image in pixels
*/
width: number;
width: number
/**
* Height of the image in pixels
*/
height: number;
height: number
}
export interface shareAlbumQuery_album_media_downloads_mediaUrl {
__typename: "MediaURL";
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string;
url: string
/**
* Width of the image in pixels
*/
width: number;
width: number
/**
* Height of the image in pixels
*/
height: number;
height: number
/**
* The file size of the resource in bytes
*/
fileSize: number;
fileSize: number
}
export interface shareAlbumQuery_album_media_downloads {
__typename: "MediaDownload";
title: string;
mediaUrl: shareAlbumQuery_album_media_downloads_mediaUrl;
__typename: 'MediaDownload'
title: string
mediaUrl: shareAlbumQuery_album_media_downloads_mediaUrl
}
export interface shareAlbumQuery_album_media_highRes {
__typename: "MediaURL";
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string;
url: string
/**
* Width of the image in pixels
*/
width: number;
width: number
/**
* Height of the image in pixels
*/
height: number;
height: number
}
export interface shareAlbumQuery_album_media_videoWeb {
__typename: "MediaURL";
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string;
url: string
}
export interface shareAlbumQuery_album_media_exif {
__typename: "MediaEXIF";
__typename: 'MediaEXIF'
/**
* The model name of the camera
*/
camera: string | null;
camera: string | null
/**
* The maker of the camera
*/
maker: string | null;
maker: string | null
/**
* The name of the lens
*/
lens: string | null;
dateShot: any | null;
lens: string | null
dateShot: any | null
/**
* The exposure time of the image
*/
exposure: number | null;
exposure: number | null
/**
* The aperature stops of the image
*/
aperture: number | null;
aperture: number | null
/**
* The ISO setting of the image
*/
iso: number | null;
iso: number | null
/**
* The focal length of the lens, when the image was taken
*/
focalLength: number | null;
focalLength: number | null
/**
* A formatted description of the flash settings, when the image was taken
*/
flash: number | null;
flash: number | null
/**
* An index describing the mode for adjusting the exposure of the image
*/
exposureProgram: number | null;
exposureProgram: number | null
}
export interface shareAlbumQuery_album_media {
__typename: "Media";
id: string;
title: string;
type: MediaType;
__typename: 'Media'
id: string
title: string
type: MediaType
/**
* URL to display the media in a smaller resolution
*/
thumbnail: shareAlbumQuery_album_media_thumbnail | null;
downloads: shareAlbumQuery_album_media_downloads[];
thumbnail: shareAlbumQuery_album_media_thumbnail | null
downloads: shareAlbumQuery_album_media_downloads[]
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: shareAlbumQuery_album_media_highRes | null;
highRes: shareAlbumQuery_album_media_highRes | null
/**
* URL to get the video in a web format that can be played in the browser, will be null for photos
*/
videoWeb: shareAlbumQuery_album_media_videoWeb | null;
exif: shareAlbumQuery_album_media_exif | null;
videoWeb: shareAlbumQuery_album_media_videoWeb | null
exif: shareAlbumQuery_album_media_exif | null
}
export interface shareAlbumQuery_album {
__typename: "Album";
id: string;
title: string;
__typename: 'Album'
id: string
title: string
/**
* The albums contained in this album
*/
subAlbums: shareAlbumQuery_album_subAlbums[];
subAlbums: shareAlbumQuery_album_subAlbums[]
/**
* The media inside this album
*/
media: shareAlbumQuery_album_media[];
media: shareAlbumQuery_album_media[]
}
export interface shareAlbumQuery {
@@ -182,13 +185,15 @@ export interface shareAlbumQuery {
* Get album by id, user must own the album or be admin
* If valid tokenCredentials are provided, the album may be retrived without further authentication
*/
album: shareAlbumQuery_album;
album: shareAlbumQuery_album
}
export interface shareAlbumQueryVariables {
id: string;
token: string;
password?: string | null;
limit?: number | null;
offset?: number | null;
id: string
token: string
password?: string | null
mediaOrderBy?: string | null
mediaOrderDirection?: OrderDirection | null
limit?: number | null
offset?: number | null
}