diff --git a/api/gqlgen.yml b/api/gqlgen.yml index 8d8d6f76..c49c42ab 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 08d80d2a..8b3ed2a4 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 dca422f6..4c91c357 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 34fe1523..2be354d3 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 dd55fed4..bb88d29f 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 4df4166f..7d7a44e4 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 f5968bec..189deb9d 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 059e5050..18301770 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 = } - let albumLink = null + let albumPath = null const mediaAlbum = media.album if (!isNil(mediaAlbum)) { - albumLink = ( -
-

- {t('sidebar.media.album', 'Album')} -

+ const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => ( +
  • - {mediaAlbum.title} + {album.title} +
  • + )) + + albumPath = ( +
    +

    + {t('sidebar.media.album_path', 'Album path')} +

    + {pathElms}
    ) } @@ -198,7 +218,7 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => { )}
    - {albumLink} + {albumPath} {sidebarMap} @@ -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 3371b2cc..49188ac0 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
    {face.faceGroup.label ?? 'unlabeled'}
    + const { t } = useTranslation() + + return ( +
  • + + + +
    + {face.faceGroup.label ?? + t('people_page.face_group.unlabeled', 'Unlabeled')} + +
    +
  • + ) } type MediaSidebarFacesProps = { @@ -18,16 +42,21 @@ type MediaSidebarFacesProps = { const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => { const { t } = useTranslation() + const faceElms = (media.faces ?? []).map(face => ( )) + if (faceElms.length == 0) return null + return ( {t('sidebar.people.title', 'People')} -
    {faceElms}
    +
    +
      {faceElms}
    +
    ) } diff --git a/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts b/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts index 93882393..01b6199e 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 00000000..9e8e5ba2 --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/icons/peopleDotsIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/helpers/utils.ts b/ui/src/helpers/utils.ts index 8879ea33..6f9042e4 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 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 bf9dcd2a..2fa6056c 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) => ( ) => (