From 06fd1664833f016cea9c8994c6c09a748ef45fbb Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Tue, 19 Oct 2021 23:28:23 +0200
Subject: [PATCH] Sidebar: people section + album path

---
 api/gqlgen.yml                                |  2 +
 api/graphql/generated.go                      | 30 ++++++++----
 api/graphql/models/face_detection.go          | 13 +++++
 api/graphql/resolvers/faces.go                |  8 ++++
 ui/package-lock.json                          | 11 +++++
 ui/package.json                               |  7 +--
 ui/src/components/album/AlbumTitle.tsx        |  6 ++-
 .../sidebar/MediaSidebar/MediaSidebar.tsx     | 47 ++++++++++++++-----
 .../MediaSidebar/MediaSidebarPeople.tsx       | 33 ++++++++++++-
 .../__generated__/sidebarMediaQuery.ts        | 34 ++++++++++++++
 .../MediaSidebar/icons/peopleDotsIcon.svg     |  3 ++
 ui/src/helpers/utils.ts                       |  6 +++
 ui/src/primitives/form/Input.tsx              | 11 ++++-
 13 files changed, 182 insertions(+), 29 deletions(-)
 create mode 100644 ui/src/components/sidebar/MediaSidebar/icons/peopleDotsIcon.svg

diff --git a/api/gqlgen.yml b/api/gqlgen.yml
index 8d8d6f7..c49c42a 100644
--- a/api/gqlgen.yml
+++ b/api/gqlgen.yml
@@ -60,6 +60,8 @@ models:
     fields:
       faceGroup:
         resolver: true
+      media:
+        resolver: true
   FaceRectangle:
     model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
   SiteInfo:
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 08d80d2..8b3ed2a 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -288,6 +288,8 @@ type FaceGroupResolver interface {
 	ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error)
 }
 type ImageFaceResolver interface {
+	Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error)
+
 	FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error)
 }
 type MediaResolver interface {
@@ -2054,6 +2056,7 @@ type MediaEXIF {
   flash: Int
   "An index describing the mode for adjusting the exposure of the image"
   exposureProgram: Int
+  "GPS coordinates of where the image was taken"
   coordinates: Coordinates
 }
 
@@ -3892,14 +3895,14 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
 		Object:     "ImageFace",
 		Field:      field,
 		Args:       nil,
-		IsMethod:   false,
-		IsResolver: false,
+		IsMethod:   true,
+		IsResolver: true,
 	}
 
 	ctx = graphql.WithFieldContext(ctx, fc)
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
-		return obj.Media, nil
+		return ec.resolvers.ImageFace().Media(rctx, obj)
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -3911,9 +3914,9 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
 		}
 		return graphql.Null
 	}
-	res := resTmp.(models.Media)
+	res := resTmp.(*models.Media)
 	fc.Result = res
-	return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
+	return ec.marshalNMedia2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) {
@@ -10692,10 +10695,19 @@ func (ec *executionContext) _ImageFace(ctx context.Context, sel ast.SelectionSet
 				atomic.AddUint32(&invalids, 1)
 			}
 		case "media":
-			out.Values[i] = ec._ImageFace_media(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				atomic.AddUint32(&invalids, 1)
-			}
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._ImageFace_media(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
 		case "rectangle":
 			out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj)
 			if out.Values[i] == graphql.Null {
diff --git a/api/graphql/models/face_detection.go b/api/graphql/models/face_detection.go
index dca422f..4c91c35 100644
--- a/api/graphql/models/face_detection.go
+++ b/api/graphql/models/face_detection.go
@@ -31,6 +31,19 @@ type ImageFace struct {
 	Rectangle   FaceRectangle  `gorm:"not null"`
 }
 
+func (f *ImageFace) FillMedia(db *gorm.DB) error {
+	if f.Media.ID != 0 {
+		// media already exists
+		return nil
+	}
+
+	if err := db.Model(&f).Association("Media").Find(&f.Media); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 type FaceDescriptor face.Descriptor
 
 // GormDataType datatype used in database
diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go
index 34fe152..2be354d 100644
--- a/api/graphql/resolvers/faces.go
+++ b/api/graphql/resolvers/faces.go
@@ -46,6 +46,14 @@ func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace)
 	return &faceGroup, nil
 }
 
+func (r imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
+	if err := obj.FillMedia(r.Database); err != nil {
+		return nil, err
+	}
+
+	return &obj.Media, nil
+}
+
 func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
 	user := auth.UserFromContext(ctx)
 	if user == nil {
diff --git a/ui/package-lock.json b/ui/package-lock.json
index dd55fed..bb88d29 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -49,6 +49,7 @@
         "react-test-renderer": "^17.0.2",
         "styled-components": "^5.3.0",
         "subscriptions-transport-ws": "^0.9.19",
+        "tailwind-override": "^0.2.3",
         "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
         "typescript": "^4.3.5",
         "url-join": "^4.0.1"
@@ -24041,6 +24042,11 @@
         "url": "https://github.com/chalk/slice-ansi?sponsor=1"
       }
     },
+    "node_modules/tailwind-override": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/tailwind-override/-/tailwind-override-0.2.3.tgz",
+      "integrity": "sha512-psWRqXL3TiI2h/YtzRq7dwKO6N7CrsEs4v99rNHgqEclfx4IioM0cHZ9O6pzerV3E6bZi6DhCbeq0z67Xs5PIQ=="
+    },
     "node_modules/tailwindcss": {
       "name": "@tailwindcss/postcss7-compat",
       "version": "2.2.4",
@@ -46037,6 +46043,11 @@
         }
       }
     },
+    "tailwind-override": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/tailwind-override/-/tailwind-override-0.2.3.tgz",
+      "integrity": "sha512-psWRqXL3TiI2h/YtzRq7dwKO6N7CrsEs4v99rNHgqEclfx4IioM0cHZ9O6pzerV3E6bZi6DhCbeq0z67Xs5PIQ=="
+    },
     "tailwindcss": {
       "version": "npm:@tailwindcss/postcss7-compat@2.2.4",
       "resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz",
diff --git a/ui/package.json b/ui/package.json
index 4df4166..7d7a44e 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -49,6 +49,7 @@
     "react-test-renderer": "^17.0.2",
     "styled-components": "^5.3.0",
     "subscriptions-transport-ws": "^0.9.19",
+    "tailwind-override": "^0.2.3",
     "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
     "typescript": "^4.3.5",
     "url-join": "^4.0.1"
@@ -71,12 +72,12 @@
     "@testing-library/jest-dom": "^5.14.1",
     "@testing-library/react": "^12.0.0",
     "@testing-library/user-event": "^13.1.9",
+    "apollo": "2.33.4",
+    "apollo-language-server": "1.26.3",
     "husky": "^6.0.0",
     "i18next-parser": "^4.2.0",
     "lint-staged": "^11.0.1",
-    "tsc-files": "^1.1.2",
-    "apollo": "2.33.4",
-    "apollo-language-server": "1.26.3"
+    "tsc-files": "^1.1.2"
   },
   "prettier": {
     "trailingComma": "es5",
diff --git a/ui/src/components/album/AlbumTitle.tsx b/ui/src/components/album/AlbumTitle.tsx
index f5968be..189deb9 100644
--- a/ui/src/components/album/AlbumTitle.tsx
+++ b/ui/src/components/album/AlbumTitle.tsx
@@ -10,8 +10,10 @@ import useDelay from '../../hooks/useDelay'
 
 import { ReactComponent as GearIcon } from './icons/gear.svg'
 
-const BreadcrumbList = styled.ol`
-  & li::after {
+export const BreadcrumbList = styled.ol<{ hideLastArrow?: boolean }>`
+  &
+    ${({ hideLastArrow }) =>
+      hideLastArrow ? 'li:not(:last-child)::after' : 'li::after'} {
     content: '';
     background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='5px' height='6px' viewBox='0 0 5 6'%3E%3Cpolyline fill='none' stroke='%23979797' points='0.74 0.167710644 3.57228936 3 0.74 5.83228936' /%3E%3C/svg%3E");
     width: 5px;
diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx
index 059e505..1830177 100644
--- a/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx
+++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx
@@ -23,12 +23,13 @@ import MediaSidebarMap from './MediaSidebarMap'
 import {
   sidebarMediaQuery,
   sidebarMediaQueryVariables,
-  sidebarMediaQuery_media_album,
+  sidebarMediaQuery_media_album_path,
   sidebarMediaQuery_media_exif,
   sidebarMediaQuery_media_faces,
   sidebarMediaQuery_media_thumbnail,
   sidebarMediaQuery_media_videoMetadata,
 } from './__generated__/sidebarMediaQuery'
+import { BreadcrumbList } from '../../album/AlbumTitle'
 
 const SIDEBAR_MEDIA_QUERY = gql`
   query sidebarMediaQuery($id: ID!) {
@@ -82,6 +83,10 @@ const SIDEBAR_MEDIA_QUERY = gql`
       album {
         id
         title
+        path {
+          id
+          title
+        }
       }
       faces {
         id
@@ -95,6 +100,15 @@ const SIDEBAR_MEDIA_QUERY = gql`
           id
           label
         }
+        media {
+          id
+          title
+          thumbnail {
+            url
+            width
+            height
+          }
+        }
       }
     }
   }
@@ -162,20 +176,26 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
     sidebarMap = <MediaSidebarMap coordinates={mediaCoordinates} />
   }
 
-  let albumLink = null
+  let albumPath = null
   const mediaAlbum = media.album
   if (!isNil(mediaAlbum)) {
-    albumLink = (
-      <div className="mx-4 my-4">
-        <h2 className="uppercase text-xs text-gray-900 font-semibold">
-          {t('sidebar.media.album', 'Album')}
-        </h2>
+    const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => (
+      <li key={album.id} className="inline-block hover:underline">
         <Link
           className="text-blue-900 hover:underline"
-          to={`/album/${mediaAlbum.id}`}
+          to={`/album/${album.id}`}
         >
-          {mediaAlbum.title}
+          {album.title}
         </Link>
+      </li>
+    ))
+
+    albumPath = (
+      <div className="mx-4 my-4">
+        <h2 className="uppercase text-xs text-gray-900 font-semibold">
+          {t('sidebar.media.album_path', 'Album path')}
+        </h2>
+        <BreadcrumbList hideLastArrow={true}>{pathElms}</BreadcrumbList>
       </div>
     )
   }
@@ -198,7 +218,7 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
         )}
       </div>
       <ExifDetails media={media} />
-      {albumLink}
+      {albumPath}
       <MediaSidebarPeople media={media} />
       {sidebarMap}
       <SidebarMediaDownload media={media} />
@@ -232,7 +252,12 @@ export interface MediaSidebarMedia {
   exif?: sidebarMediaQuery_media_exif | null
   faces?: sidebarMediaQuery_media_faces[]
   downloads?: sidebarDownloadQuery_media_downloads[]
-  album?: sidebarMediaQuery_media_album
+  album?: {
+    __typename: 'Album'
+    id: string
+    title: string
+    path?: sidebarMediaQuery_media_album_path[]
+  }
 }
 
 type MediaSidebarType = {
diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx
index 3371b2c..49188ac 100644
--- a/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx
+++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx
@@ -1,15 +1,39 @@
 import React from 'react'
 import { useTranslation } from 'react-i18next'
+import { Link } from 'react-router-dom'
+import FaceCircleImage from '../../../Pages/PeoplePage/FaceCircleImage'
+import { Button } from '../../../primitives/form/Input'
 import { SidebarSection, SidebarSectionTitle } from '../SidebarComponents'
 import { MediaSidebarMedia } from './MediaSidebar'
 import { sidebarMediaQuery_media_faces } from './__generated__/sidebarMediaQuery'
 
+import { ReactComponent as PeopleDotsIcon } from './icons/peopleDotsIcon.svg'
+
 type MediaSidebarFaceProps = {
   face: sidebarMediaQuery_media_faces
 }
 
 const MediaSidebarPerson = ({ face }: MediaSidebarFaceProps) => {
-  return <div>{face.faceGroup.label ?? 'unlabeled'}</div>
+  const { t } = useTranslation()
+
+  return (
+    <li className="inline-block">
+      <Link to={`/people/${face.faceGroup.id}`}>
+        <FaceCircleImage imageFace={face} selectable={true} size="100px" />
+      </Link>
+      <div
+        className={`text-center ${
+          face.faceGroup.label ? 'text-black' : 'text-gray-600'
+        }`}
+      >
+        {face.faceGroup.label ??
+          t('people_page.face_group.unlabeled', 'Unlabeled')}
+        <Button className="px-2 py-1.5 align-middle ml-1">
+          <PeopleDotsIcon className="text-gray-500" />
+        </Button>
+      </div>
+    </li>
+  )
 }
 
 type MediaSidebarFacesProps = {
@@ -18,16 +42,21 @@ type MediaSidebarFacesProps = {
 
 const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => {
   const { t } = useTranslation()
+
   const faceElms = (media.faces ?? []).map(face => (
     <MediaSidebarPerson key={face.id} face={face} />
   ))
 
+  if (faceElms.length == 0) return null
+
   return (
     <SidebarSection>
       <SidebarSectionTitle>
         {t('sidebar.people.title', 'People')}
       </SidebarSectionTitle>
-      <div>{faceElms}</div>
+      <div className="overflow-x-auto">
+        <ul className="flex gap-4 mx-4">{faceElms}</ul>
+      </div>
     </SidebarSection>
   )
 }
diff --git a/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts b/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts
index 9388239..01b6199 100644
--- a/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts
+++ b/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts
@@ -128,10 +128,17 @@ export interface sidebarMediaQuery_media_exif {
   coordinates: sidebarMediaQuery_media_exif_coordinates | null
 }
 
+export interface sidebarMediaQuery_media_album_path {
+  __typename: 'Album'
+  id: string
+  title: string
+}
+
 export interface sidebarMediaQuery_media_album {
   __typename: 'Album'
   id: string
   title: string
+  path: sidebarMediaQuery_media_album_path[]
 }
 
 export interface sidebarMediaQuery_media_faces_rectangle {
@@ -148,11 +155,38 @@ export interface sidebarMediaQuery_media_faces_faceGroup {
   label: string | null
 }
 
+export interface sidebarMediaQuery_media_faces_media_thumbnail {
+  __typename: 'MediaURL'
+  /**
+   * URL for previewing the image
+   */
+  url: string
+  /**
+   * Width of the image in pixels
+   */
+  width: number
+  /**
+   * Height of the image in pixels
+   */
+  height: number
+}
+
+export interface sidebarMediaQuery_media_faces_media {
+  __typename: 'Media'
+  id: string
+  title: string
+  /**
+   * URL to display the media in a smaller resolution
+   */
+  thumbnail: sidebarMediaQuery_media_faces_media_thumbnail | null
+}
+
 export interface sidebarMediaQuery_media_faces {
   __typename: 'ImageFace'
   id: string
   rectangle: sidebarMediaQuery_media_faces_rectangle
   faceGroup: sidebarMediaQuery_media_faces_faceGroup
+  media: sidebarMediaQuery_media_faces_media
 }
 
 export interface sidebarMediaQuery_media {
diff --git a/ui/src/components/sidebar/MediaSidebar/icons/peopleDotsIcon.svg b/ui/src/components/sidebar/MediaSidebar/icons/peopleDotsIcon.svg
new file mode 100644
index 0000000..9e8e5ba
--- /dev/null
+++ b/ui/src/components/sidebar/MediaSidebar/icons/peopleDotsIcon.svg
@@ -0,0 +1,3 @@
+<svg width="12px" height="3px" viewBox="0 0 8 2" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <path d="M1,0 C1.55228475,0 2,0.44771525 2,1 C2,1.55228475 1.55228475,2 1,2 C0.44771525,2 0,1.55228475 0,1 C0,0.44771525 0.44771525,0 1,0 Z M4,0 C4.55228475,0 5,0.44771525 5,1 C5,1.55228475 4.55228475,2 4,2 C3.44771525,2 3,1.55228475 3,1 C3,0.44771525 3.44771525,0 4,0 Z M7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 Z" fill="currentColor"></path>
+</svg>
\ No newline at end of file
diff --git a/ui/src/helpers/utils.ts b/ui/src/helpers/utils.ts
index 8879ea3..6f9042e 100644
--- a/ui/src/helpers/utils.ts
+++ b/ui/src/helpers/utils.ts
@@ -1,3 +1,5 @@
+import classNames, { Argument as ClassNamesArg } from 'classnames'
+import { overrideTailwindClasses } from 'tailwind-override'
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
 export interface DebouncedFn<F extends (...args: any[]) => any> {
@@ -41,3 +43,7 @@ export function isNil(value: any): value is undefined | null {
 export function exhaustiveCheck(value: never) {
   throw new Error(`Exhaustive check failed with value: ${value}`)
 }
+
+export function tailwindClassNames(...args: ClassNamesArg[]) {
+  return overrideTailwindClasses(classNames(args))
+}
diff --git a/ui/src/primitives/form/Input.tsx b/ui/src/primitives/form/Input.tsx
index bf9dcd2..2fa6056 100644
--- a/ui/src/primitives/form/Input.tsx
+++ b/ui/src/primitives/form/Input.tsx
@@ -3,6 +3,7 @@ import classNames, { Argument as ClassNamesArg } from 'classnames'
 import { ReactComponent as ActionArrowIcon } from './icons/textboxActionArrow.svg'
 import { ReactComponent as LoadingSpinnerIcon } from './icons/textboxLoadingSpinner.svg'
 import styled from 'styled-components'
+import { tailwindClassNames } from '../../helpers/utils'
 
 type TextFieldProps = {
   label?: string
@@ -164,7 +165,10 @@ export const Submit = ({
   ...props
 }: SubmitProps & React.ButtonHTMLAttributes<HTMLInputElement>) => (
   <input
-    className={classNames(buttonStyles({ variant, background }), className)}
+    className={tailwindClassNames(
+      buttonStyles({ variant, background }),
+      className
+    )}
     type="submit"
     value={children}
     {...props}
@@ -179,7 +183,10 @@ export const Button = ({
   ...props
 }: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
   <button
-    className={classNames(buttonStyles({ variant, background }), className)}
+    className={tailwindClassNames(
+      buttonStyles({ variant, background }),
+      className
+    )}
     {...props}
   >
     {children}
