From 360db25ec32da84ddc45e6e0b1c82bd3961571dc Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Wed, 19 May 2021 21:32:49 +0200
Subject: [PATCH] Make people page translatable

---
 ui/extractedTranslations/da/translation.json  |  52 +++++++-
 ui/extractedTranslations/de/translation.json  |  52 +++++++-
 ui/extractedTranslations/en/translation.json  |  52 +++++++-
 ui/extractedTranslations/es/translation.json  |  52 +++++++-
 ui/extractedTranslations/fr/translation.json  |  52 +++++++-
 ui/extractedTranslations/it/translation.json  |  52 +++++++-
 ui/extractedTranslations/pl/translation.json  |  52 +++++++-
 ui/extractedTranslations/sv/translation.json  |  52 +++++++-
 ...acesModal.js => DetachImageFacesModal.tsx} |  68 +++++++---
 .../SingleFaceGroup/FaceGroupTitle.tsx        |  65 ++++++---
 .../SingleFaceGroup/MergeFaceGroupsModal.js   |  94 -------------
 .../SingleFaceGroup/MergeFaceGroupsModal.tsx  | 124 ++++++++++++++++++
 .../SingleFaceGroup/MoveImageFacesModal.tsx   |  44 +++++--
 .../SingleFaceGroup/SelectFaceGroupTable.tsx  |   8 +-
 .../SingleFaceGroup/SelectImageFacesTable.tsx |   8 +-
 .../SingleFaceGroup/SingleFaceGroup.tsx       |   5 +-
 16 files changed, 671 insertions(+), 161 deletions(-)
 rename ui/src/Pages/PeoplePage/SingleFaceGroup/{DetachImageFacesModal.js => DetachImageFacesModal.tsx} (52%)
 delete mode 100644 ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js
 create mode 100644 ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx

diff --git a/ui/extractedTranslations/da/translation.json b/ui/extractedTranslations/da/translation.json
index b30c101..c4c49d4 100644
--- a/ui/extractedTranslations/da/translation.json
+++ b/ui/extractedTranslations/da/translation.json
@@ -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": "Navn",
-      "unlabeled": "Ikke navngivet"
+      "unlabeled": "Ikke navngivet",
+      "unlabeled_person": null
     },
-    "recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter"
+    "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": "Genkend ikke navngivede ansigter",
+    "table": {
+      "select_face_group": {
+        "search_faces_placeholder": null
+      },
+      "select_image_faces": {
+        "search_images_placeholder": null
+      }
+    }
   },
   "photos_page": {
     "title": "Billeder"
diff --git a/ui/extractedTranslations/de/translation.json b/ui/extractedTranslations/de/translation.json
index 558a37b..542e988 100644
--- a/ui/extractedTranslations/de/translation.json
+++ b/ui/extractedTranslations/de/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/en/translation.json b/ui/extractedTranslations/en/translation.json
index 3d9820c..21881e0 100644
--- a/ui/extractedTranslations/en/translation.json
+++ b/ui/extractedTranslations/en/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/es/translation.json b/ui/extractedTranslations/es/translation.json
index b52e82a..3be1421 100644
--- a/ui/extractedTranslations/es/translation.json
+++ b/ui/extractedTranslations/es/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/fr/translation.json b/ui/extractedTranslations/fr/translation.json
index 2078abe..8a847c6 100644
--- a/ui/extractedTranslations/fr/translation.json
+++ b/ui/extractedTranslations/fr/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/it/translation.json b/ui/extractedTranslations/it/translation.json
index 34b3c62..05cd708 100644
--- a/ui/extractedTranslations/it/translation.json
+++ b/ui/extractedTranslations/it/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/pl/translation.json b/ui/extractedTranslations/pl/translation.json
index 96d9d10..83ec26d 100644
--- a/ui/extractedTranslations/pl/translation.json
+++ b/ui/extractedTranslations/pl/translation.json
@@ -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"
diff --git a/ui/extractedTranslations/sv/translation.json b/ui/extractedTranslations/sv/translation.json
index aab3904..ce1a52c 100644
--- a/ui/extractedTranslations/sv/translation.json
+++ b/ui/extractedTranslations/sv/translation.json
@@ -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"
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx
similarity index 52%
rename from ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js
rename to ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx
index 7a53d87..d770131 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.js
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx
@@ -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
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx
index 6979578..377663d 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx
@@ -1,5 +1,6 @@
 import { useMutation } from '@apollo/client'
 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'
@@ -38,6 +39,8 @@ type FaceGroupTitleProps = {
 }
 
 const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
+  const { t } = useTranslation()
+
   const [editLabel, setEditLabel] = useState(false)
   const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
   const inputRef = createRef<Input>()
@@ -91,7 +94,8 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
     title = (
       <TitleWrapper>
         <TitleLabel labeled={!!faceGroup?.label}>
-          {faceGroup?.label ?? 'Unlabeled person'}
+          {faceGroup?.label ??
+            t('people_page.face_group.unlabeled_person', 'Unlabeled person')}
         </TitleLabel>
         <TitleDropdown
           icon={{
@@ -102,22 +106,32 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
           <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>
@@ -130,7 +144,7 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
         <Input
           loading={setLabelLoading}
           ref={inputRef}
-          placeholder="Label"
+          placeholder={t('people_page.face_group.label_placeholder', 'Label')}
           icon="arrow right"
           value={inputValue}
           onKeyUp={onKeyUp}
@@ -143,24 +157,33 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
     )
   }
 
+  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}
     </>
   )
 }
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js
deleted file mode 100644
index 6d724fa..0000000
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js
+++ /dev/null
@@ -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
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx
new file mode 100644
index 0000000..64ef5d5
--- /dev/null
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx
@@ -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
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx
index 226245d..8f38127 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx
@@ -20,6 +20,7 @@ import {
   moveImageFaces,
   moveImageFacesVariables,
 } from './__generated__/moveImageFaces'
+import { useTranslation } from 'react-i18next'
 
 const MOVE_IMAGE_FACES_MUTATION = gql`
   mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
@@ -38,7 +39,7 @@ const MOVE_IMAGE_FACES_MUTATION = gql`
 type MoveImageFacesModalProps = {
   open: boolean
   setOpen: React.Dispatch<React.SetStateAction<boolean>>
-  faceGroup?: singleFaceGroup_faceGroup
+  faceGroup: singleFaceGroup_faceGroup
 }
 
 const MoveImageFacesModal = ({
@@ -46,6 +47,8 @@ const MoveImageFacesModal = ({
   setOpen,
   faceGroup,
 }: MoveImageFacesModalProps) => {
+  const { t } = useTranslation()
+
   const [selectedImageFaces, setSelectedImageFaces] = useState<
     (singleFaceGroup_faceGroup_imageFaces | myFaces_myFaceGroups_imageFaces)[]
   >([])
@@ -102,7 +105,7 @@ const MoveImageFacesModal = ({
     })
   }
 
-  const imageFaces = faceGroup?.imageFaces ?? []
+  const imageFaces = faceGroup.imageFaces
 
   let table = null
   if (!imagesSelected) {
@@ -111,7 +114,10 @@ const MoveImageFacesModal = ({
         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 {
@@ -121,14 +127,17 @@ const MoveImageFacesModal = ({
       )
       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>
     }
   }
 
@@ -137,7 +146,10 @@ const MoveImageFacesModal = ({
     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)}
@@ -148,7 +160,10 @@ const MoveImageFacesModal = ({
     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()}
@@ -163,15 +178,24 @@ const MoveImageFacesModal = ({
       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>
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.tsx
index 7dd411b..d7ef5fb 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.tsx
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectFaceGroupTable.tsx
@@ -1,4 +1,5 @@
 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'
@@ -66,6 +67,8 @@ const SelectFaceGroupTable = ({
   setSelectedFaceGroup,
   title,
 }: SelectFaceGroupTableProps) => {
+  const { t } = useTranslation()
+
   const PAGE_SIZE = 6
 
   const [page, setPage] = useState(0)
@@ -106,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>
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.tsx
index ea618be..b33b057 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.tsx
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SelectImageFacesTable.tsx
@@ -1,4 +1,5 @@
 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'
@@ -67,6 +68,8 @@ const SelectImageFacesTable = ({
   setSelectedImageFaces,
   title,
 }: SelectImageFacesTable) => {
+  const { t } = useTranslation()
+
   const PAGE_SIZE = 6
 
   const [page, setPage] = useState(0)
@@ -115,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>
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx
index f72a838..7327040 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx
@@ -1,5 +1,6 @@
 import { gql, useQuery } from '@apollo/client'
 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'
@@ -47,6 +48,8 @@ type SingleFaceGroupProps = {
 }
 
 const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => {
+  const { t } = useTranslation()
+
   const { data, error, loading, fetchMore } = useQuery<
     singleFaceGroup,
     singleFaceGroupVariables
@@ -94,7 +97,7 @@ const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => {
         />
         <PaginateLoader
           active={!finishedLoadingMore && !loading}
-          text="Loading more photos"
+          text={t('general.loading.paginate.media', 'Loading more media')}
         />
       </div>
     </div>
