mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 21:49:15 +00:00
Merge pull request #372 from photoview/refactor-faces-page
Fix faces page and rewrite in Typescript
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
41
ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx
Normal file
41
ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
125
ui/src/Pages/PeoplePage/FaceCircleImage.tsx
Normal file
125
ui/src/Pages/PeoplePage/FaceCircleImage.tsx
Normal 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
|
||||
223
ui/src/Pages/PeoplePage/PeoplePage.test.tsx
Normal file
223
ui/src/Pages/PeoplePage/PeoplePage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
124
ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx
Normal file
124
ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
1
ui/src/Pages/PeoplePage/__generated__/myFaces.ts
generated
1
ui/src/Pages/PeoplePage/__generated__/myFaces.ts
generated
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
121
ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts
generated
121
ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts
generated
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user