diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go
index a2fc6b84..885de7b3 100644
--- a/api/graphql/resolvers/faces.go
+++ b/api/graphql/resolvers/faces.go
@@ -2,10 +2,11 @@ package resolvers
import (
"context"
- "errors"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
+ "github.com/pkg/errors"
+ "gorm.io/gorm"
)
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
@@ -74,42 +75,51 @@ func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID in
return nil, errors.New("unauthorized")
}
- if err := user.FillAlbums(r.Database); err != nil {
+ faceGroup, err := userOwnedFaceGroup(r.Database, user, faceGroupID)
+ if err != nil {
return nil, err
}
- userAlbumIDs := make([]int, len(user.Albums))
- for i, album := range user.Albums {
- userAlbumIDs[i] = album.ID
- }
-
- // Verify that user owns at leat one of the images in the face group
- imageFaceQuery := r.Database.
- Select("image_faces.id").
- Table("image_faces").
- Joins("LEFT JOIN media ON media.id = image_faces.media_id").
- Where("media.album_id IN (?)", userAlbumIDs)
-
- faceGroupQuery := r.Database.
- Model(&models.FaceGroup{}).
- Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
- Where("face_groups.id = ?", faceGroupID).
- Where("image_faces.id IN (?)", imageFaceQuery)
-
- var faceGroup models.FaceGroup
- if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
+ if err := r.Database.Model(faceGroup).Update("label", label).Error; err != nil {
return nil, err
}
- if err := r.Database.Model(&faceGroup).Update("label", label).Error; err != nil {
- return nil, err
- }
-
- return &faceGroup, nil
+ return faceGroup, nil
}
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
- panic("not implemented")
+ user := auth.UserFromContext(ctx)
+ if user == nil {
+ return nil, errors.New("unauthorized")
+ }
+
+ destinationFaceGroup, err := userOwnedFaceGroup(r.Database, user, destinationFaceGroupID)
+ if err != nil {
+ return nil, err
+ }
+
+ sourceFaceGroup, err := userOwnedFaceGroup(r.Database, user, sourceFaceGroupID)
+ if err != nil {
+ return nil, err
+ }
+
+ updateError := r.Database.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&models.ImageFace{}).Where("face_group_id = ?", sourceFaceGroup.ID).Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
+ return err
+ }
+
+ if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
+ return err
+ }
+
+ return nil
+ })
+
+ if updateError != nil {
+ return nil, updateError
+ }
+
+ return destinationFaceGroup, nil
}
func (r *mutationResolver) MoveImageFace(ctx context.Context, imageFaceID int, newFaceGroupID int) (*models.ImageFace, error) {
@@ -119,3 +129,46 @@ func (r *mutationResolver) MoveImageFace(ctx context.Context, imageFaceID int, n
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
panic("not implemented")
}
+
+func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
+ if user.Admin {
+ var faceGroup models.FaceGroup
+ if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
+ return nil, err
+ }
+
+ return &faceGroup, nil
+ }
+
+ if err := user.FillAlbums(db); err != nil {
+ return nil, err
+ }
+
+ userAlbumIDs := make([]int, len(user.Albums))
+ for i, album := range user.Albums {
+ userAlbumIDs[i] = album.ID
+ }
+
+ // Verify that user owns at leat one of the images in the face group
+ imageFaceQuery := db.
+ Select("image_faces.id").
+ Table("image_faces").
+ Joins("LEFT JOIN media ON media.id = image_faces.media_id").
+ Where("media.album_id IN (?)", userAlbumIDs)
+
+ faceGroupQuery := db.
+ Model(&models.FaceGroup{}).
+ Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
+ Where("face_groups.id = ?", faceGroupID).
+ Where("image_faces.id IN (?)", imageFaceQuery)
+
+ var faceGroup models.FaceGroup
+ if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, errors.Wrap(err, "face group does not exist or is not owned by the user")
+ }
+ return nil, err
+ }
+
+ return &faceGroup, nil
+}
diff --git a/ui/src/Pages/PeoplePage/FaceCircleImage.js b/ui/src/Pages/PeoplePage/FaceCircleImage.js
new file mode 100644
index 00000000..4df50d57
--- /dev/null
+++ b/ui/src/Pages/PeoplePage/FaceCircleImage.js
@@ -0,0 +1,97 @@
+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' }) => {
+ 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 (
+
+
+
+ )
+}
+
+FaceCircleImage.propTypes = {
+ imageFace: PropTypes.object.isRequired,
+ selectable: PropTypes.bool,
+ size: PropTypes.string,
+}
+
+export default FaceCircleImage
diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js
index f0b34a42..d64e5455 100644
--- a/ui/src/Pages/PeoplePage/PeoplePage.js
+++ b/ui/src/Pages/PeoplePage/PeoplePage.js
@@ -1,13 +1,14 @@
-import React from 'react'
+import React, { createRef, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
-import { gql, useQuery } from '@apollo/client'
+import { gql, useMutation, useQuery } from '@apollo/client'
import Layout from '../../Layout'
-import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
-import SingleFaceGroup from './SingleFaceGroup'
+import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
+import { Icon, Input } from 'semantic-ui-react'
+import FaceCircleImage from './FaceCircleImage'
-const MY_FACES_QUERY = gql`
+export const MY_FACES_QUERY = gql`
query myFaces {
myFaceGroups {
id
@@ -33,81 +34,145 @@ const MY_FACES_QUERY = gql`
}
`
-const CircleImageWrapper = styled.div`
- background-color: #eee;
- position: relative;
- border-radius: 50%;
- width: 150px;
- height: 150px;
- object-fit: fill;
- margin: 12px;
- overflow: hidden;
+export const SET_GROUP_LABEL_MUTATION = gql`
+ mutation($groupID: ID!, $label: String) {
+ setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
+ id
+ label
+ }
+ }
`
-const FaceImagePortrait = styled(ProtectedImage)`
- position: absolute;
- 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)})`};
-
- transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
- object-fit: cover;
-`
-
-const FaceImageLandscape = styled(ProtectedImage)`
- position: absolute;
- 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)})`};
-
- transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
- object-fit: cover;
-`
-
-const FaceLabel = styled.div`
+const FaceDetailsButton = styled.button`
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
- margin: 12px 12px 24px;
+ margin: 12px auto 24px;
text-align: center;
+ display: block;
+ background: none;
+ border: none;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ color: #2683ca;
+ }
+`
+
+const FaceLabel = styled.span``
+
+const FaceDetails = ({ group }) => {
+ const [editLabel, setEditLabel] = useState(false)
+ const [inputValue, setInputValue] = useState(group.label ?? '')
+ const inputRef = createRef()
+
+ const [setGroupLabel, { loading }] = useMutation(SET_GROUP_LABEL_MUTATION, {
+ variables: {
+ groupID: group.id,
+ },
+ })
+
+ const resetLabel = () => {
+ setInputValue(group.label ?? '')
+ setEditLabel(false)
+ }
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [inputRef])
+
+ useEffect(() => {
+ if (!loading) {
+ resetLabel()
+ }
+ }, [loading])
+
+ const onKeyUp = e => {
+ if (e.key == 'Escape') {
+ resetLabel()
+ return
+ }
+
+ if (e.key == 'Enter') {
+ setGroupLabel({
+ variables: {
+ label: e.target.value == '' ? null : e.target.value,
+ },
+ })
+ return
+ }
+ }
+
+ let label
+ if (!editLabel) {
+ label = (
+ setEditLabel(true)}
+ >
+ {group.imageFaces.length}
+ {group.label ?? 'Unlabeled'}
+
+
+ )
+ } else {
+ label = (
+
+ setInputValue(e.target.value)}
+ onBlur={() => {
+ resetLabel()
+ }}
+ />
+
+ )
+ }
+
+ return label
+}
+
+FaceDetails.propTypes = {
+ group: PropTypes.object.isRequired,
+}
+
+const FaceImagesCount = styled.span`
+ background-color: #eee;
+ color: #222;
+ font-size: 0.9em;
+ padding: 0 4px;
+ margin-right: 6px;
+ border-radius: 4px;
+`
+
+const EditIcon = styled(Icon)`
+ margin-left: 6px !important;
+ opacity: 0 !important;
+
+ transition: opacity 100ms;
+
+ ${FaceDetailsButton}:hover &, ${FaceDetailsButton}:focus-visible & {
+ opacity: 1 !important;
+ }
`
const FaceGroup = ({ group }) => {
const previewFace = group.imageFaces[0]
- const rect = previewFace.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 FaceImage =
- previewFace.media.thumbnail.width > previewFace.media.thumbnail.height
- ? FaceImageLandscape
- : FaceImagePortrait
-
return (
-
-
-
-
-
- {group.label ?? 'Unlabeled'}
-
-
+
+
+
+
+
+
)
}
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.js
new file mode 100644
index 00000000..edf71789
--- /dev/null
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.js
@@ -0,0 +1,141 @@
+import { useMutation } from '@apollo/client'
+import PropTypes from 'prop-types'
+import React, { useState, useEffect, createRef } from 'react'
+import { Dropdown, Input } from 'semantic-ui-react'
+import styled from 'styled-components'
+import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
+import MergeFaceGroupsModal from './MergeFaceGroupsModal'
+
+const TitleWrapper = styled.div`
+ min-height: 3.5em;
+`
+
+const TitleLabel = styled.h1`
+ display: inline-block;
+ color: ${({ labeled }) => (labeled ? 'black' : '#888')};
+ margin-right: 12px;
+`
+
+const TitleDropdown = styled(Dropdown)`
+ vertical-align: middle;
+ margin-top: -10px;
+ color: #888;
+
+ &:hover {
+ color: #1e70bf;
+ }
+`
+
+const FaceGroupTitle = ({ faceGroup }) => {
+ const [editLabel, setEditLabel] = useState(false)
+ const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
+ const inputRef = createRef()
+ const [mergeModalOpen, setMergeModalOpen] = useState(false)
+
+ const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
+ SET_GROUP_LABEL_MUTATION,
+ {
+ variables: {
+ groupID: faceGroup?.id,
+ },
+ }
+ )
+
+ const resetLabel = () => {
+ setInputValue(faceGroup.label ?? '')
+ setEditLabel(false)
+ }
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [inputRef])
+
+ useEffect(() => {
+ if (!setLabelLoading) {
+ resetLabel()
+ }
+ }, [setLabelLoading])
+
+ const onKeyUp = e => {
+ if (e.key == 'Escape') {
+ resetLabel()
+ return
+ }
+
+ if (e.key == 'Enter') {
+ setGroupLabel({
+ variables: {
+ label: e.target.value == '' ? null : e.target.value,
+ },
+ })
+ return
+ }
+ }
+
+ let title
+ if (!editLabel) {
+ title = (
+
+
+ {faceGroup?.label ?? 'Unlabeled person'}
+
+
+
+ setEditLabel(true)}
+ />
+ setMergeModalOpen(true)}
+ />
+
+
+
+ )
+ } else {
+ title = (
+
+ setInputValue(e.target.value)}
+ onBlur={() => {
+ resetLabel()
+ }}
+ />
+
+ )
+ }
+
+ return (
+ <>
+ {title}
+
+ >
+ )
+}
+
+FaceGroupTitle.propTypes = {
+ faceGroup: PropTypes.object,
+}
+
+export default FaceGroupTitle
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js
new file mode 100644
index 00000000..5742f98a
--- /dev/null
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.js
@@ -0,0 +1,182 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Modal,
+ Button,
+ Header,
+ Table,
+ Input,
+ Pagination,
+} from 'semantic-ui-react'
+import FaceCircleImage from '../FaceCircleImage'
+import { gql, useMutation, useQuery } from '@apollo/client'
+import { MY_FACES_QUERY } from '../PeoplePage'
+import styled from 'styled-components'
+import { Redirect } from 'react-router-dom'
+
+const COMBINE_FACES_MUTATION = gql`
+ mutation($destID: ID!, $srcID: ID!) {
+ combineFaceGroups(
+ destinationFaceGroupID: $destID
+ sourceFaceGroupID: $srcID
+ ) {
+ id
+ }
+ }
+`
+
+const FaceCircleWrapper = styled.div`
+ display: inline-block;
+ border-radius: 50%;
+ border: 2px solid
+ ${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
+`
+
+const FaceGroupRowStyled = styled(Table.Row)``
+
+const FaceGroupCell = styled(Table.Cell)`
+ display: flex;
+ align-items: center;
+`
+
+const RowLabel = styled.span`
+ ${({ $selected }) => $selected && `font-weight: bold;`}
+ margin-left: 12px;
+`
+
+const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
+ return (
+
+
+
+
+
+ {faceGroup.label}
+
+
+ )
+}
+
+FaceGroupRow.propTypes = {
+ faceGroup: PropTypes.object.isRequired,
+ faceSelected: PropTypes.bool.isRequired,
+ setFaceSelected: PropTypes.func.isRequired,
+}
+
+const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
+ const [page, setPage] = useState(0)
+ const [selectedRow, setSelectedRow] = useState(null)
+ const [mergedFaceGroup, setMergedFaceGroup] = useState(false)
+ const PAGE_SIZE = 8
+ const { data } = useQuery(MY_FACES_QUERY)
+ const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
+ variables: {
+ srcID: sourceFaceGroup.id,
+ },
+ refetchQueries: [
+ {
+ query: MY_FACES_QUERY,
+ },
+ ],
+ })
+
+ const mergeFaceGroups = () => {
+ const destFaceGroup = data.myFaceGroups.filter(
+ x => x.id != sourceFaceGroup.id
+ )[selectedRow]
+
+ combineFacesMutation({
+ variables: {
+ destID: destFaceGroup.id,
+ },
+ onCompleted() {
+ setMergedFaceGroup(destFaceGroup.id)
+ },
+ })
+ }
+
+ if (mergedFaceGroup) {
+ return
+ }
+
+ const rows = data?.myFaceGroups
+ .filter(x => x.id != sourceFaceGroup.id)
+ .filter((_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE)
+ .map((face, i) => (
+ setSelectedRow(i + page * PAGE_SIZE)}
+ />
+ ))
+
+ return (
+ setOpen(false)}
+ onOpen={() => setOpen(true)}
+ open={open}
+ >
+ Merge Face Groups
+
+
+ Select the destination face below
+
+
+
+
+
+
+
+ )
+}
+
+MergeFaceGroupsModal.propTypes = {
+ open: PropTypes.bool.isRequired,
+ setOpen: PropTypes.func.isRequired,
+ sourceFaceGroup: PropTypes.object.isRequired,
+}
+
+export default MergeFaceGroupsModal
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup.js b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js
similarity index 62%
rename from ui/src/Pages/PeoplePage/SingleFaceGroup.js
rename to ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js
index 6becb53c..5a32e5d8 100644
--- a/ui/src/Pages/PeoplePage/SingleFaceGroup.js
+++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.js
@@ -1,7 +1,8 @@
-import React from 'react'
import PropTypes from 'prop-types'
-import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
-import PhotoGallery from '../../components/photoGallery/PhotoGallery'
+import React from 'react'
+import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
+import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
+import FaceGroupTitle from './FaceGroupTitle'
const ImageFace = ({ imageFace }) => {
return (
@@ -17,19 +18,10 @@ ImageFace.propTypes = {
}
const SingleFaceGroup = ({ faceGroup }) => {
- if (!faceGroup) {
- return null
- }
-
- // const images = faceGroup.imageFaces.map(imgFace => (
- //
- // ))
-
- const media = faceGroup.imageFaces.map(x => x.media)
-
- return (
-
- Face group: {faceGroup.id}
+ let mediaGallery = null
+ if (faceGroup) {
+ const media = faceGroup.imageFaces.map(x => x.media)
+ mediaGallery = (
{
onSelectImage={() => {}}
/>
+ )
+ }
+
+ return (
+
+
+ {mediaGallery}
)
}