1
Fork 0

Add functionality to sidebar people menu

This commit is contained in:
viktorstrate 2021-10-21 15:57:41 +02:00
parent e27f653c2e
commit df57f55ac4
No known key found for this signature in database
GPG Key ID: 3F855605109C1E8A
22 changed files with 286 additions and 105 deletions

View File

@ -144,7 +144,11 @@ describe('FaceDetails component', () => {
render(
<MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={emptyFaceGroup} />
<FaceDetails
editLabel={false}
setEditLabel={jest.fn()}
group={emptyFaceGroup}
/>
</MockedProvider>
)
@ -159,7 +163,11 @@ describe('FaceDetails component', () => {
render(
<MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={labeledFaceGroup} />
<FaceDetails
editLabel={false}
setEditLabel={jest.fn()}
group={labeledFaceGroup}
/>
</MockedProvider>
)
@ -190,7 +198,11 @@ describe('FaceDetails component', () => {
]
render(
<MockedProvider mocks={graphqlMocks} addTypename={false}>
<FaceDetails group={faceGroup} />
<FaceDetails
editLabel={false}
setEditLabel={jest.fn()}
group={faceGroup}
/>
</MockedProvider>
)

View File

@ -19,6 +19,7 @@ import {
myFaces_myFaceGroups,
} from './__generated__/myFaces'
import { recognizeUnlabeledFaces } from './__generated__/recognizeUnlabeledFaces'
import { tailwindClassNames } from '../../helpers/utils'
export const MY_FACES_QUERY = gql`
query myFaces($limit: Int, $offset: Int) {
@ -65,15 +66,8 @@ const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
}
`
const FaceDetailsWrapper = styled.div<{ labeled: boolean }>`
const FaceDetailsWrapper = styled.span<{ labeled: boolean }>`
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
width: 150px;
margin: 12px auto 24px;
text-align: center;
display: block;
background: none;
border: none;
cursor: pointer;
&:hover,
&:focus-visible {
@ -82,12 +76,26 @@ const FaceDetailsWrapper = styled.div<{ labeled: boolean }>`
`
type FaceDetailsProps = {
group: myFaces_myFaceGroups
group: {
__typename: 'FaceGroup'
id: string
label: string | null
imageFaceCount: number
}
className?: string
textFieldClassName?: string
editLabel: boolean
setEditLabel: React.Dispatch<React.SetStateAction<boolean>>
}
export const FaceDetails = ({ group }: FaceDetailsProps) => {
export const FaceDetails = ({
group,
className,
textFieldClassName,
editLabel,
setEditLabel,
}: FaceDetailsProps) => {
const { t } = useTranslation()
const [editLabel, setEditLabel] = useState(false)
const [inputValue, setInputValue] = useState(group.label ?? '')
const inputRef = createRef<HTMLInputElement>()
@ -126,11 +134,15 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
if (!editLabel) {
label = (
<FaceDetailsWrapper
className={tailwindClassNames(
className,
'whitespace-nowrap inline-block overflow-hidden overflow-clip'
)}
labeled={!!group.label}
onClick={() => setEditLabel(true)}
>
<FaceImagesCount>{group.imageFaceCount}</FaceImagesCount>
<button>
<button className="">
{group.label ?? t('people_page.face_group.unlabeled', 'Unlabeled')}
</button>
{/* <EditIcon name="pencil" /> */}
@ -138,9 +150,9 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
)
} else {
label = (
<FaceDetailsWrapper labeled={!!group.label}>
<FaceDetailsWrapper className={className} labeled={!!group.label}>
<TextField
className="w-[160px]"
className={textFieldClassName}
loading={loading}
ref={inputRef}
// size="mini"
@ -183,13 +195,20 @@ type FaceGroupProps = {
const FaceGroup = ({ group }: FaceGroupProps) => {
const previewFace = group.imageFaces[0]
const [editLabel, setEditLabel] = useState(false)
return (
<div style={{ margin: '12px' }}>
<Link to={`/people/${group.id}`}>
<FaceCircleImage imageFace={previewFace} selectable />
</Link>
<FaceDetails group={group} />
<FaceDetails
className="block cursor-pointer text-center w-full mt-3"
textFieldClassName="w-[140px]"
group={group}
editLabel={editLabel}
setEditLabel={setEditLabel}
/>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { gql, useMutation } from '@apollo/client'
import { BaseMutationOptions, gql, useMutation } from '@apollo/client'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
@ -28,16 +28,50 @@ const DETACH_IMAGE_FACES_MUTATION = gql`
}
`
export const useDetachImageFaces = (
mutationOptions: BaseMutationOptions<
detachImageFaces,
detachImageFacesVariables
>
) => {
const [detachImageFacesMutation] = useMutation<
detachImageFaces,
detachImageFacesVariables
>(DETACH_IMAGE_FACES_MUTATION, mutationOptions)
return async (
selectedImageFaces: (
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
)[]
) => {
const faceIDs = selectedImageFaces.map(face => face.id)
const result = await detachImageFacesMutation({
variables: {
faceIDs,
},
})
return result
}
}
type DetachImageFacesModalProps = {
open: boolean
setOpen(open: boolean): void
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
selectedImageFaces?: (
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
)[]
}
const DetachImageFacesModal = ({
open,
setOpen,
faceGroup,
selectedImageFaces: selectedImageFacesProp,
}: DetachImageFacesModalProps) => {
const { t } = useTranslation()
@ -46,10 +80,7 @@ const DetachImageFacesModal = ({
>([])
const history = useHistory()
const [detachImageFacesMutation] = useMutation<
detachImageFaces,
detachImageFacesVariables
>(DETACH_IMAGE_FACES_MUTATION, {
const detachImageFacesMutation = useDetachImageFaces({
refetchQueries: [
{
query: MY_FACES_QUERY,
@ -57,6 +88,19 @@ const DetachImageFacesModal = ({
],
})
const detachImageFaces = () => {
detachImageFacesMutation(selectedImageFaces).then(({ data }) => {
if (isNil(data)) throw new Error('Expected data not to be null')
setOpen(false)
history.push(`/people/${data.detachImageFaces.id}`)
})
}
useEffect(() => {
if (isNil(selectedImageFacesProp)) return
setSelectedImageFaces(selectedImageFacesProp)
}, [selectedImageFacesProp])
useEffect(() => {
if (!open) {
setSelectedImageFaces([])
@ -65,20 +109,6 @@ const DetachImageFacesModal = ({
if (open == false) return null
const detachImageFaces = () => {
const faceIDs = selectedImageFaces.map(face => face.id)
detachImageFacesMutation({
variables: {
faceIDs,
},
}).then(({ data }) => {
if (isNil(data)) throw new Error('Expected data not to be null')
setOpen(false)
history.push(`/people/${data.detachImageFaces.id}`)
})
}
const imageFaces = faceGroup?.imageFaces ?? []
return (
@ -94,7 +124,7 @@ const DetachImageFacesModal = ({
actions={[
{
key: 'cancel',
label: 'Cancel',
label: t('general.action.cancel', 'Cancel'),
onClick: () => setOpen(false),
},
{

View File

@ -8,7 +8,7 @@ import React, {
import { useTranslation } from 'react-i18next'
import { isNil } from '../../../helpers/utils'
import { Button, TextField } from '../../../primitives/form/Input'
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
import { MY_FACES_QUERY, SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
import {
setGroupLabel,
setGroupLabelVariables,
@ -112,6 +112,11 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
open={mergeModalOpen}
setOpen={setMergeModalOpen}
sourceFaceGroup={faceGroup}
refetchQueries={[
{
query: MY_FACES_QUERY,
},
]}
/>
<MoveImageFacesModal
open={moveModalOpen}

View File

@ -1,4 +1,4 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import { gql, PureQueryOptions, useMutation, useQuery } from '@apollo/client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
@ -31,18 +31,24 @@ const COMBINE_FACES_MUTATION = gql`
type MergeFaceGroupsModalProps = {
open: boolean
setOpen(open: boolean): void
sourceFaceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
sourceFaceGroup: {
__typename: 'FaceGroup'
id: string
}
refetchQueries: PureQueryOptions[]
}
const MergeFaceGroupsModal = ({
open,
setOpen,
sourceFaceGroup,
refetchQueries,
}: MergeFaceGroupsModalProps) => {
const { t } = useTranslation()
const [selectedFaceGroup, setSelectedFaceGroup] =
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
const [selectedFaceGroup, setSelectedFaceGroup] = useState<
myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
>(null)
const history = useHistory()
const { data } = useQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
@ -50,11 +56,7 @@ const MergeFaceGroupsModal = ({
combineFaces,
combineFacesVariables
>(COMBINE_FACES_MUTATION, {
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
refetchQueries: refetchQueries,
})
if (open == false) return null

View File

@ -40,20 +40,26 @@ type MoveImageFacesModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
faceGroup: singleFaceGroup_faceGroup
preselectedImageFaces?: (
| singleFaceGroup_faceGroup_imageFaces
| myFaces_myFaceGroups_imageFaces
)[]
}
const MoveImageFacesModal = ({
open,
setOpen,
faceGroup,
preselectedImageFaces,
}: MoveImageFacesModalProps) => {
const { t } = useTranslation()
const [selectedImageFaces, setSelectedImageFaces] = useState<
(singleFaceGroup_faceGroup_imageFaces | myFaces_myFaceGroups_imageFaces)[]
>([])
const [selectedFaceGroup, setSelectedFaceGroup] =
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
const [selectedFaceGroup, setSelectedFaceGroup] = useState<
myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
>(null)
const [imagesSelected, setImagesSelected] = useState(false)
const history = useHistory()
@ -68,8 +74,16 @@ const MoveImageFacesModal = ({
],
})
const [loadFaceGroups, { data: faceGroupsData }] =
useLazyQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery<
myFaces,
myFacesVariables
>(MY_FACES_QUERY)
useEffect(() => {
if (isNil(preselectedImageFaces)) return
setSelectedImageFaces(preselectedImageFaces)
setImagesSelected(true)
}, [preselectedImageFaces])
useEffect(() => {
if (imagesSelected) {

View File

@ -31,7 +31,7 @@ import {
} from './__generated__/sidebarMediaQuery'
import { BreadcrumbList } from '../../album/AlbumTitle'
const SIDEBAR_MEDIA_QUERY = gql`
export const SIDEBAR_MEDIA_QUERY = gql`
query sidebarMediaQuery($id: ID!) {
media(id: $id) {
id
@ -99,6 +99,7 @@ const SIDEBAR_MEDIA_QUERY = gql`
faceGroup {
id
label
imageFaceCount
}
media {
id

View File

@ -1,34 +1,46 @@
import React from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { Link, useHistory } from 'react-router-dom'
import FaceCircleImage from '../../../Pages/PeoplePage/FaceCircleImage'
import { SidebarSection, SidebarSectionTitle } from '../SidebarComponents'
import { MediaSidebarMedia } from './MediaSidebar'
import { MediaSidebarMedia, SIDEBAR_MEDIA_QUERY } from './MediaSidebar'
import { sidebarMediaQuery_media_faces } from './__generated__/sidebarMediaQuery'
import { ReactComponent as PeopleDotsIcon } from './icons/peopleDotsIcon.svg'
import { Menu } from '@headlessui/react'
import { Button } from '../../../primitives/form/Input'
import { ArrowPopoverPanel } from '../Sharing'
import { tailwindClassNames } from '../../../helpers/utils'
import { isNil, tailwindClassNames } from '../../../helpers/utils'
import MergeFaceGroupsModal from '../../../Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal'
import { useDetachImageFaces } from '../../../Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal'
import MoveImageFacesModal from '../../../Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal'
import { FaceDetails } from '../../../Pages/PeoplePage/PeoplePage'
type PersonMoreMenuItemProps = {
label: string
className?: string
onClick(): void
}
const PersonMoreMenuItem = ({ label, className }: PersonMoreMenuItemProps) => {
const PersonMoreMenuItem = ({
label,
className,
onClick,
}: PersonMoreMenuItemProps) => {
return (
<Menu.Item>
{({ active }) => (
<a
<button
onClick={onClick}
className={tailwindClassNames(
`block py-1 cursor-pointer ${active && 'bg-gray-50 text-black'}`,
`whitespace-normal w-full block py-1 cursor-pointer ${
active ? 'bg-gray-50 text-black' : 'text-gray-700'
}`,
className
)}
>
{label}
</a>
</button>
)}
</Menu.Item>
)
@ -36,40 +48,105 @@ const PersonMoreMenuItem = ({ label, className }: PersonMoreMenuItemProps) => {
type PersonMoreMenuProps = {
face: sidebarMediaQuery_media_faces
setChangeLabel: React.Dispatch<React.SetStateAction<boolean>>
className?: string
}
const PersonMoreMenu = ({ face }: PersonMoreMenuProps) => {
const PersonMoreMenu = ({
face,
setChangeLabel,
className,
}: PersonMoreMenuProps) => {
const { t } = useTranslation()
face
const [mergeModalOpen, setMergeModalOpen] = useState(false)
const [moveModalOpen, setMoveModalOpen] = useState(false)
const refetchQueries = [
{
query: SIDEBAR_MEDIA_QUERY,
variables: {
id: face.media.id,
},
},
]
const history = useHistory()
const detachImageFaceMutation = useDetachImageFaces({
refetchQueries,
})
const modals = (
<>
<MergeFaceGroupsModal
sourceFaceGroup={face.faceGroup}
open={mergeModalOpen}
setOpen={setMergeModalOpen}
refetchQueries={refetchQueries}
/>
<MoveImageFacesModal
faceGroup={{ imageFaces: [], ...face.faceGroup }}
open={moveModalOpen}
setOpen={setMoveModalOpen}
preselectedImageFaces={[face]}
/>
</>
)
const detachImageFace = () => {
if (
!confirm(
t(
'sidebar.people.confirm_image_detach',
'Are you sure you want to detach this image?'
)
)
)
return
detachImageFaceMutation([face]).then(({ data }) => {
if (isNil(data)) throw new Error('Expected data not to be null')
history.push(`/people/${data.detachImageFaces.id}`)
})
}
return (
<Menu as="div" className="relative inline-block">
<Menu.Button as={Button} className="px-1.5 py-1.5 align-middle ml-1">
<PeopleDotsIcon className="text-gray-500" />
</Menu.Button>
<Menu.Items className="">
<ArrowPopoverPanel width={120}>
<PersonMoreMenuItem
className="border-b"
label={t('people_page.action_label.change_label', 'Change label')}
/>
<PersonMoreMenuItem
className="border-b"
label={t('sidebar.people.action_label.merge_face', 'Merge face')}
/>
<PersonMoreMenuItem
className="border-b"
label={t(
'sidebar.people.action_label.detach_image',
'Detach image'
)}
/>
<PersonMoreMenuItem
label={t('sidebar.people.action_label.move_face', 'Move face')}
/>
</ArrowPopoverPanel>
</Menu.Items>
</Menu>
<>
<Menu
as="div"
className={tailwindClassNames('relative inline-block', className)}
>
<Menu.Button as={Button} className="px-1.5 py-1.5 align-middle ml-1">
<PeopleDotsIcon className="text-gray-500" />
</Menu.Button>
<Menu.Items className="">
<ArrowPopoverPanel width={120}>
<PersonMoreMenuItem
onClick={() => setChangeLabel(true)}
className="border-b"
label={t('people_page.action_label.change_label', 'Change label')}
/>
<PersonMoreMenuItem
onClick={() => setMergeModalOpen(true)}
className="border-b"
label={t('sidebar.people.action_label.merge_face', 'Merge face')}
/>
<PersonMoreMenuItem
onClick={() => detachImageFace()}
className="border-b"
label={t(
'sidebar.people.action_label.detach_image',
'Detach image'
)}
/>
<PersonMoreMenuItem
onClick={() => setMoveModalOpen(true)}
label={t('sidebar.people.action_label.move_face', 'Move face')}
/>
</ArrowPopoverPanel>
</Menu.Items>
</Menu>
{modals}
</>
)
}
@ -78,21 +155,28 @@ type MediaSidebarFaceProps = {
}
const MediaSidebarPerson = ({ face }: MediaSidebarFaceProps) => {
const { t } = useTranslation()
const [changeLabel, setChangeLabel] = useState(false)
return (
<li className="inline-block">
<Link to={`/people/${face.faceGroup.id}`}>
<FaceCircleImage imageFace={face} selectable={true} size="92px" />
</Link>
<div
className={`text-center text-sm mt-1 ${
face.faceGroup.label ? 'text-black' : 'text-gray-600'
}`}
>
{face.faceGroup.label ??
t('people_page.face_group.unlabeled', 'Unlabeled')}
<PersonMoreMenu face={face} />
<div className="mt-1 whitespace-nowrap">
<FaceDetails
className="text-sm max-w-[80px] align-middle"
textFieldClassName="w-[100px]"
group={face.faceGroup}
editLabel={changeLabel}
setEditLabel={setChangeLabel}
/>
{!changeLabel && (
<PersonMoreMenu
className="pl-0.5"
face={face}
setChangeLabel={setChangeLabel}
/>
)}
</div>
</li>
)

View File

@ -153,6 +153,7 @@ export interface sidebarMediaQuery_media_faces_faceGroup {
__typename: 'FaceGroup'
id: string
label: string | null
imageFaceCount: number
}
export interface sidebarMediaQuery_media_faces_media_thumbnail {

View File

@ -263,7 +263,7 @@
"title": "Lokation"
},
"media": {
"album_path": "",
"album_path": "Album sti",
"exif": {
"exposure_program": {
"action_program": "Actionprogram",
@ -306,10 +306,11 @@
},
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
"detach_image": "Løsriv billede",
"merge_face": "Sammenflet person",
"move_face": "Flyt person"
},
"confirm_image_detach": "Er du sikker på at du vil løsrive dette billede?",
"title": "Personer"
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "Merge face",
"move_face": "Move face"
},
"confirm_image_detach": "Are you sure you want to detach this image?",
"title": "People"
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -315,6 +315,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -315,6 +315,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -310,6 +310,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -305,6 +305,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -305,6 +305,7 @@
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": {

View File

@ -1,5 +1,5 @@
import classNames, { Argument as ClassNamesArg } from 'classnames'
import { overrideTailwindClasses } from 'tailwind-override'
// import { overrideTailwindClasses } from 'tailwind-override'
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface DebouncedFn<F extends (...args: any[]) => any> {
@ -45,5 +45,6 @@ export function exhaustiveCheck(value: never) {
}
export function tailwindClassNames(...args: ClassNamesArg[]) {
return overrideTailwindClasses(classNames(args))
// return overrideTailwindClasses(classNames(args))
return classNames(args)
}