1
Fork 0

Sidebar: people section + album path

This commit is contained in:
viktorstrate 2021-10-19 23:28:23 +02:00
parent ca9bb092f9
commit 06fd166483
No known key found for this signature in database
GPG Key ID: 3F855605109C1E8A
13 changed files with 182 additions and 29 deletions

View File

@ -60,6 +60,8 @@ models:
fields: fields:
faceGroup: faceGroup:
resolver: true resolver: true
media:
resolver: true
FaceRectangle: FaceRectangle:
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
SiteInfo: SiteInfo:

View File

@ -288,6 +288,8 @@ type FaceGroupResolver interface {
ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error)
} }
type ImageFaceResolver interface { type ImageFaceResolver interface {
Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error)
FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error)
} }
type MediaResolver interface { type MediaResolver interface {
@ -2054,6 +2056,7 @@ type MediaEXIF {
flash: Int flash: Int
"An index describing the mode for adjusting the exposure of the image" "An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int exposureProgram: Int
"GPS coordinates of where the image was taken"
coordinates: Coordinates coordinates: Coordinates
} }
@ -3892,14 +3895,14 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
Object: "ImageFace", Object: "ImageFace",
Field: field, Field: field,
Args: nil, Args: nil,
IsMethod: false, IsMethod: true,
IsResolver: false, IsResolver: true,
} }
ctx = graphql.WithFieldContext(ctx, fc) ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return obj.Media, nil return ec.resolvers.ImageFace().Media(rctx, obj)
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -3911,9 +3914,9 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
} }
return graphql.Null return graphql.Null
} }
res := resTmp.(models.Media) res := resTmp.(*models.Media)
fc.Result = res fc.Result = res
return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) return ec.marshalNMedia2githubᚗ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) { 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) atomic.AddUint32(&invalids, 1)
} }
case "media": case "media":
out.Values[i] = ec._ImageFace_media(ctx, field, obj) field := field
if out.Values[i] == graphql.Null { 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) atomic.AddUint32(&invalids, 1)
} }
return res
})
case "rectangle": case "rectangle":
out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj) out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {

View File

@ -31,6 +31,19 @@ type ImageFace struct {
Rectangle FaceRectangle `gorm:"not null"` 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 type FaceDescriptor face.Descriptor
// GormDataType datatype used in database // GormDataType datatype used in database

View File

@ -46,6 +46,14 @@ func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace)
return &faceGroup, nil 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) { func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
user := auth.UserFromContext(ctx) user := auth.UserFromContext(ctx)
if user == nil { if user == nil {

11
ui/package-lock.json generated
View File

@ -49,6 +49,7 @@
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"styled-components": "^5.3.0", "styled-components": "^5.3.0",
"subscriptions-transport-ws": "^0.9.19", "subscriptions-transport-ws": "^0.9.19",
"tailwind-override": "^0.2.3",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"url-join": "^4.0.1" "url-join": "^4.0.1"
@ -24041,6 +24042,11 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1" "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": { "node_modules/tailwindcss": {
"name": "@tailwindcss/postcss7-compat", "name": "@tailwindcss/postcss7-compat",
"version": "2.2.4", "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": { "tailwindcss": {
"version": "npm:@tailwindcss/postcss7-compat@2.2.4", "version": "npm:@tailwindcss/postcss7-compat@2.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz",

View File

@ -49,6 +49,7 @@
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"styled-components": "^5.3.0", "styled-components": "^5.3.0",
"subscriptions-transport-ws": "^0.9.19", "subscriptions-transport-ws": "^0.9.19",
"tailwind-override": "^0.2.3",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"url-join": "^4.0.1" "url-join": "^4.0.1"
@ -71,12 +72,12 @@
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0", "@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.1.9", "@testing-library/user-event": "^13.1.9",
"apollo": "2.33.4",
"apollo-language-server": "1.26.3",
"husky": "^6.0.0", "husky": "^6.0.0",
"i18next-parser": "^4.2.0", "i18next-parser": "^4.2.0",
"lint-staged": "^11.0.1", "lint-staged": "^11.0.1",
"tsc-files": "^1.1.2", "tsc-files": "^1.1.2"
"apollo": "2.33.4",
"apollo-language-server": "1.26.3"
}, },
"prettier": { "prettier": {
"trailingComma": "es5", "trailingComma": "es5",

View File

@ -10,8 +10,10 @@ import useDelay from '../../hooks/useDelay'
import { ReactComponent as GearIcon } from './icons/gear.svg' import { ReactComponent as GearIcon } from './icons/gear.svg'
const BreadcrumbList = styled.ol` export const BreadcrumbList = styled.ol<{ hideLastArrow?: boolean }>`
& li::after { &
${({ hideLastArrow }) =>
hideLastArrow ? 'li:not(:last-child)::after' : 'li::after'} {
content: ''; 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"); 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; width: 5px;

View File

@ -23,12 +23,13 @@ import MediaSidebarMap from './MediaSidebarMap'
import { import {
sidebarMediaQuery, sidebarMediaQuery,
sidebarMediaQueryVariables, sidebarMediaQueryVariables,
sidebarMediaQuery_media_album, sidebarMediaQuery_media_album_path,
sidebarMediaQuery_media_exif, sidebarMediaQuery_media_exif,
sidebarMediaQuery_media_faces, sidebarMediaQuery_media_faces,
sidebarMediaQuery_media_thumbnail, sidebarMediaQuery_media_thumbnail,
sidebarMediaQuery_media_videoMetadata, sidebarMediaQuery_media_videoMetadata,
} from './__generated__/sidebarMediaQuery' } from './__generated__/sidebarMediaQuery'
import { BreadcrumbList } from '../../album/AlbumTitle'
const SIDEBAR_MEDIA_QUERY = gql` const SIDEBAR_MEDIA_QUERY = gql`
query sidebarMediaQuery($id: ID!) { query sidebarMediaQuery($id: ID!) {
@ -82,6 +83,10 @@ const SIDEBAR_MEDIA_QUERY = gql`
album { album {
id id
title title
path {
id
title
}
} }
faces { faces {
id id
@ -95,6 +100,15 @@ const SIDEBAR_MEDIA_QUERY = gql`
id id
label label
} }
media {
id
title
thumbnail {
url
width
height
}
}
} }
} }
} }
@ -162,20 +176,26 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
sidebarMap = <MediaSidebarMap coordinates={mediaCoordinates} /> sidebarMap = <MediaSidebarMap coordinates={mediaCoordinates} />
} }
let albumLink = null let albumPath = null
const mediaAlbum = media.album const mediaAlbum = media.album
if (!isNil(mediaAlbum)) { if (!isNil(mediaAlbum)) {
albumLink = ( const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => (
<div className="mx-4 my-4"> <li key={album.id} className="inline-block hover:underline">
<h2 className="uppercase text-xs text-gray-900 font-semibold">
{t('sidebar.media.album', 'Album')}
</h2>
<Link <Link
className="text-blue-900 hover:underline" className="text-blue-900 hover:underline"
to={`/album/${mediaAlbum.id}`} to={`/album/${album.id}`}
> >
{mediaAlbum.title} {album.title}
</Link> </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> </div>
) )
} }
@ -198,7 +218,7 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
)} )}
</div> </div>
<ExifDetails media={media} /> <ExifDetails media={media} />
{albumLink} {albumPath}
<MediaSidebarPeople media={media} /> <MediaSidebarPeople media={media} />
{sidebarMap} {sidebarMap}
<SidebarMediaDownload media={media} /> <SidebarMediaDownload media={media} />
@ -232,7 +252,12 @@ export interface MediaSidebarMedia {
exif?: sidebarMediaQuery_media_exif | null exif?: sidebarMediaQuery_media_exif | null
faces?: sidebarMediaQuery_media_faces[] faces?: sidebarMediaQuery_media_faces[]
downloads?: sidebarDownloadQuery_media_downloads[] downloads?: sidebarDownloadQuery_media_downloads[]
album?: sidebarMediaQuery_media_album album?: {
__typename: 'Album'
id: string
title: string
path?: sidebarMediaQuery_media_album_path[]
}
} }
type MediaSidebarType = { type MediaSidebarType = {

View File

@ -1,15 +1,39 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' 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 { SidebarSection, SidebarSectionTitle } from '../SidebarComponents'
import { MediaSidebarMedia } from './MediaSidebar' import { MediaSidebarMedia } from './MediaSidebar'
import { sidebarMediaQuery_media_faces } from './__generated__/sidebarMediaQuery' import { sidebarMediaQuery_media_faces } from './__generated__/sidebarMediaQuery'
import { ReactComponent as PeopleDotsIcon } from './icons/peopleDotsIcon.svg'
type MediaSidebarFaceProps = { type MediaSidebarFaceProps = {
face: sidebarMediaQuery_media_faces face: sidebarMediaQuery_media_faces
} }
const MediaSidebarPerson = ({ face }: MediaSidebarFaceProps) => { 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 = { type MediaSidebarFacesProps = {
@ -18,16 +42,21 @@ type MediaSidebarFacesProps = {
const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => { const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const faceElms = (media.faces ?? []).map(face => ( const faceElms = (media.faces ?? []).map(face => (
<MediaSidebarPerson key={face.id} face={face} /> <MediaSidebarPerson key={face.id} face={face} />
)) ))
if (faceElms.length == 0) return null
return ( return (
<SidebarSection> <SidebarSection>
<SidebarSectionTitle> <SidebarSectionTitle>
{t('sidebar.people.title', 'People')} {t('sidebar.people.title', 'People')}
</SidebarSectionTitle> </SidebarSectionTitle>
<div>{faceElms}</div> <div className="overflow-x-auto">
<ul className="flex gap-4 mx-4">{faceElms}</ul>
</div>
</SidebarSection> </SidebarSection>
) )
} }

View File

@ -128,10 +128,17 @@ export interface sidebarMediaQuery_media_exif {
coordinates: sidebarMediaQuery_media_exif_coordinates | null coordinates: sidebarMediaQuery_media_exif_coordinates | null
} }
export interface sidebarMediaQuery_media_album_path {
__typename: 'Album'
id: string
title: string
}
export interface sidebarMediaQuery_media_album { export interface sidebarMediaQuery_media_album {
__typename: 'Album' __typename: 'Album'
id: string id: string
title: string title: string
path: sidebarMediaQuery_media_album_path[]
} }
export interface sidebarMediaQuery_media_faces_rectangle { export interface sidebarMediaQuery_media_faces_rectangle {
@ -148,11 +155,38 @@ export interface sidebarMediaQuery_media_faces_faceGroup {
label: string | null 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 { export interface sidebarMediaQuery_media_faces {
__typename: 'ImageFace' __typename: 'ImageFace'
id: string id: string
rectangle: sidebarMediaQuery_media_faces_rectangle rectangle: sidebarMediaQuery_media_faces_rectangle
faceGroup: sidebarMediaQuery_media_faces_faceGroup faceGroup: sidebarMediaQuery_media_faces_faceGroup
media: sidebarMediaQuery_media_faces_media
} }
export interface sidebarMediaQuery_media { export interface sidebarMediaQuery_media {

View File

@ -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>

After

Width:  |  Height:  |  Size: 583 B

View File

@ -1,3 +1,5 @@
import classNames, { Argument as ClassNamesArg } from 'classnames'
import { overrideTailwindClasses } from 'tailwind-override'
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
export interface DebouncedFn<F extends (...args: any[]) => 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) { export function exhaustiveCheck(value: never) {
throw new Error(`Exhaustive check failed with value: ${value}`) throw new Error(`Exhaustive check failed with value: ${value}`)
} }
export function tailwindClassNames(...args: ClassNamesArg[]) {
return overrideTailwindClasses(classNames(args))
}

View File

@ -3,6 +3,7 @@ import classNames, { Argument as ClassNamesArg } from 'classnames'
import { ReactComponent as ActionArrowIcon } from './icons/textboxActionArrow.svg' import { ReactComponent as ActionArrowIcon } from './icons/textboxActionArrow.svg'
import { ReactComponent as LoadingSpinnerIcon } from './icons/textboxLoadingSpinner.svg' import { ReactComponent as LoadingSpinnerIcon } from './icons/textboxLoadingSpinner.svg'
import styled from 'styled-components' import styled from 'styled-components'
import { tailwindClassNames } from '../../helpers/utils'
type TextFieldProps = { type TextFieldProps = {
label?: string label?: string
@ -164,7 +165,10 @@ export const Submit = ({
...props ...props
}: SubmitProps & React.ButtonHTMLAttributes<HTMLInputElement>) => ( }: SubmitProps & React.ButtonHTMLAttributes<HTMLInputElement>) => (
<input <input
className={classNames(buttonStyles({ variant, background }), className)} className={tailwindClassNames(
buttonStyles({ variant, background }),
className
)}
type="submit" type="submit"
value={children} value={children}
{...props} {...props}
@ -179,7 +183,10 @@ export const Button = ({
...props ...props
}: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => ( }: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button <button
className={classNames(buttonStyles({ variant, background }), className)} className={tailwindClassNames(
buttonStyles({ variant, background }),
className
)}
{...props} {...props}
> >
{children} {children}