Work on face merge ui
This commit is contained in:
parent
6b48ac9a16
commit
00fceea4db
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<CircleImageWrapper size={size}>
|
||||
<SpecificFaceImage
|
||||
$selectable={selectable}
|
||||
$scale={scale}
|
||||
$origin={origin}
|
||||
src={imageFace.media.thumbnail.url}
|
||||
/>
|
||||
</CircleImageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
FaceCircleImage.propTypes = {
|
||||
imageFace: PropTypes.object.isRequired,
|
||||
selectable: PropTypes.bool,
|
||||
size: PropTypes.string,
|
||||
}
|
||||
|
||||
export default FaceCircleImage
|
|
@ -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 = (
|
||||
<FaceDetailsButton
|
||||
labeled={!!group.label}
|
||||
onClick={() => setEditLabel(true)}
|
||||
>
|
||||
<FaceImagesCount>{group.imageFaces.length}</FaceImagesCount>
|
||||
<FaceLabel>{group.label ?? 'Unlabeled'}</FaceLabel>
|
||||
<EditIcon name="pencil" />
|
||||
</FaceDetailsButton>
|
||||
)
|
||||
} else {
|
||||
label = (
|
||||
<FaceDetailsButton labeled={!!group.label}>
|
||||
<Input
|
||||
loading={loading}
|
||||
ref={inputRef}
|
||||
size="mini"
|
||||
placeholder="Label"
|
||||
icon="arrow right"
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</FaceDetailsButton>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ margin: '12px' }}>
|
||||
<Link to={`/people/${group.id}`}>
|
||||
<CircleImageWrapper>
|
||||
<FaceImage
|
||||
scale={scale}
|
||||
origin={origin}
|
||||
src={previewFace.media.thumbnail.url}
|
||||
/>
|
||||
</CircleImageWrapper>
|
||||
<FaceLabel labeled={!!group.label}>
|
||||
{group.label ?? 'Unlabeled'}
|
||||
</FaceLabel>
|
||||
<FaceCircleImage imageFace={previewFace} selectable />
|
||||
</Link>
|
||||
<FaceDetails group={group} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = (
|
||||
<TitleWrapper>
|
||||
<TitleLabel labeled={!!faceGroup?.label}>
|
||||
{faceGroup?.label ?? 'Unlabeled person'}
|
||||
</TitleLabel>
|
||||
<TitleDropdown
|
||||
icon={{
|
||||
name: 'settings',
|
||||
size: 'large',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon="pencil"
|
||||
text={faceGroup?.label ? 'Change Label' : 'Add Label'}
|
||||
onClick={() => setEditLabel(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object ungroup"
|
||||
text="Merge Face"
|
||||
alt="Merge this group into another"
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</TitleDropdown>
|
||||
</TitleWrapper>
|
||||
)
|
||||
} else {
|
||||
title = (
|
||||
<TitleWrapper>
|
||||
<Input
|
||||
loading={setLabelLoading}
|
||||
ref={inputRef}
|
||||
placeholder="Label"
|
||||
icon="arrow right"
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</TitleWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<MergeFaceGroupsModal
|
||||
open={mergeModalOpen}
|
||||
setOpen={setMergeModalOpen}
|
||||
sourceFaceGroup={faceGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
FaceGroupTitle.propTypes = {
|
||||
faceGroup: PropTypes.object,
|
||||
}
|
||||
|
||||
export default FaceGroupTitle
|
|
@ -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 (
|
||||
<FaceGroupRowStyled
|
||||
$selected={faceSelected}
|
||||
key={faceGroup.id}
|
||||
onClick={setFaceSelected}
|
||||
>
|
||||
<FaceGroupCell>
|
||||
<FaceCircleWrapper $selected={faceSelected}>
|
||||
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
|
||||
</FaceCircleWrapper>
|
||||
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
|
||||
</FaceGroupCell>
|
||||
</FaceGroupRowStyled>
|
||||
)
|
||||
}
|
||||
|
||||
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 <Redirect to={`/people/${mergedFaceGroup}`} />
|
||||
}
|
||||
|
||||
const rows = data?.myFaceGroups
|
||||
.filter(x => x.id != sourceFaceGroup.id)
|
||||
.filter((_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE)
|
||||
.map((face, i) => (
|
||||
<FaceGroupRow
|
||||
key={face.id}
|
||||
faceGroup={face}
|
||||
faceSelected={selectedRow == i + page * PAGE_SIZE}
|
||||
setFaceSelected={() => setSelectedRow(i + page * PAGE_SIZE)}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>Merge Face Groups</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
<Header>Select the destination face below</Header>
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Face group</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Input icon="search" placeholder="Search faces..." fluid />
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={data?.myFaceGroups.length / PAGE_SIZE}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
setPage(Math.ceil(activePage) - 1)
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedRow == 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.isRequired,
|
||||
}
|
||||
|
||||
export default MergeFaceGroupsModal
|
|
@ -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 => (
|
||||
// <ImageFace key={imgFace.id} imageFace={imgFace} />
|
||||
// ))
|
||||
|
||||
let mediaGallery = null
|
||||
if (faceGroup) {
|
||||
const media = faceGroup.imageFaces.map(x => x.media)
|
||||
|
||||
return (
|
||||
<div>
|
||||
Face group: {faceGroup.id}
|
||||
mediaGallery = (
|
||||
<div>
|
||||
<PhotoGallery
|
||||
media={media}
|
||||
|
@ -38,6 +30,13 @@ const SingleFaceGroup = ({ faceGroup }) => {
|
|||
onSelectImage={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FaceGroupTitle faceGroup={faceGroup} />
|
||||
{mediaGallery}
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue