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
+ + + + Face group + + + + + + + + {rows} + + + + { + setPage(Math.ceil(activePage) - 1) + }} + /> + + + +
+
+
+ + +