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 910e648..8b3ed2a 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -75,6 +75,11 @@ type ComplexityRoot struct { Token func(childComplexity int) int } + Coordinates struct { + Latitude func(childComplexity int) int + Longitude func(childComplexity int) int + } + FaceGroup struct { ID func(childComplexity int) int ImageFaceCount func(childComplexity int) int @@ -122,6 +127,7 @@ type ComplexityRoot struct { MediaExif struct { Aperture func(childComplexity int) int Camera func(childComplexity int) int + Coordinates func(childComplexity int) int DateShot func(childComplexity int) int Exposure func(childComplexity int) int ExposureProgram func(childComplexity int) int @@ -282,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 { @@ -473,6 +481,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AuthorizeResult.Token(childComplexity), true + case "Coordinates.latitude": + if e.complexity.Coordinates.Latitude == nil { + break + } + + return e.complexity.Coordinates.Latitude(childComplexity), true + + case "Coordinates.longitude": + if e.complexity.Coordinates.Longitude == nil { + break + } + + return e.complexity.Coordinates.Longitude(childComplexity), true + case "FaceGroup.id": if e.complexity.FaceGroup.ID == nil { break @@ -695,6 +717,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MediaExif.Camera(childComplexity), true + case "MediaEXIF.coordinates": + if e.complexity.MediaExif.Coordinates == nil { + break + } + + return e.complexity.MediaExif.Coordinates(childComplexity), true + case "MediaEXIF.dateShot": if e.complexity.MediaExif.DateShot == nil { break @@ -1745,7 +1774,7 @@ type Query { "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @isAuthorized "Get the mapbox api token, returns null if mapbox is not enabled" - mapboxToken: String @isAuthorized + mapboxToken: String shareToken(credentials: ShareTokenCredentials!): ShareToken! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! @@ -1955,8 +1984,6 @@ type Album { path: [Album!]! shares: [ShareToken!]! - - #coverID: Int } type MediaURL { @@ -2029,6 +2056,15 @@ 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 +} + +type Coordinates { + "GPS latitude in degrees" + latitude: Float! + "GPS longitude in degrees" + longitude: Float! } type VideoMetadata { @@ -3459,6 +3495,76 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr return ec.marshalOString2ᚖstring(ctx, field.Selections, res) } +func (ec *executionContext) _Coordinates_latitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Coordinates", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + 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.Latitude, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + +func (ec *executionContext) _Coordinates_longitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Coordinates", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + 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.Longitude, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(float64) + fc.Result = res + return ec.marshalNFloat2float64(ctx, field.Selections, res) +} + func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3789,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) @@ -3808,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) { @@ -4853,6 +4959,38 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel return ec.marshalOInt2ᚖint64(ctx, field.Selections, res) } +func (ec *executionContext) _MediaEXIF_coordinates(ctx context.Context, field graphql.CollectedField, obj *models.MediaEXIF) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MediaEXIF", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: false, + } + + 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.Coordinates(), nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.Coordinates) + fc.Result = res + return ec.marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx, field.Selections, res) +} + func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -7283,28 +7421,8 @@ func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphq ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - directive0 := func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().MapboxToken(rctx) - } - directive1 := func(ctx context.Context) (interface{}, error) { - if ec.directives.IsAuthorized == nil { - return nil, errors.New("directive isAuthorized is not implemented") - } - return ec.directives.IsAuthorized(ctx, nil, directive0) - } - - tmp, err := directive1(rctx) - if err != nil { - return nil, graphql.ErrorOnPath(ctx, err) - } - if tmp == nil { - return nil, nil - } - if data, ok := tmp.(*string); ok { - return data, nil - } - return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp) + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MapboxToken(rctx) }) if err != nil { ec.Error(ctx, err) @@ -10429,6 +10547,38 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select return out } +var coordinatesImplementors = []string{"Coordinates"} + +func (ec *executionContext) _Coordinates(ctx context.Context, sel ast.SelectionSet, obj *models.Coordinates) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, coordinatesImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Coordinates") + case "latitude": + out.Values[i] = ec._Coordinates_latitude(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "longitude": + out.Values[i] = ec._Coordinates_longitude(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var faceGroupImplementors = []string{"FaceGroup"} func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler { @@ -10545,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 { @@ -10824,6 +10983,8 @@ func (ec *executionContext) _MediaEXIF(ctx context.Context, sel ast.SelectionSet out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj) case "exposureProgram": out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj) + case "coordinates": + out.Values[i] = ec._MediaEXIF_coordinates(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -12875,6 +13036,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return graphql.MarshalBoolean(*v) } +func (ec *executionContext) marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx context.Context, sel ast.SelectionSet, v *models.Coordinates) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Coordinates(ctx, sel, v) +} + func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) { if v == nil { return nil, nil 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/models/generated.go b/api/graphql/models/generated.go index 84eaf07..e1922df 100644 --- a/api/graphql/models/generated.go +++ b/api/graphql/models/generated.go @@ -15,6 +15,13 @@ type AuthorizeResult struct { Token *string `json:"token"` } +type Coordinates struct { + // GPS latitude in degrees + Latitude float64 `json:"latitude"` + // GPS longitude in degrees + Longitude float64 `json:"longitude"` +} + type MediaDownload struct { Title string `json:"title"` MediaURL *MediaURL `json:"mediaUrl"` diff --git a/api/graphql/models/media_exif.go b/api/graphql/models/media_exif.go index dec9d67..37af1e4 100644 --- a/api/graphql/models/media_exif.go +++ b/api/graphql/models/media_exif.go @@ -28,3 +28,14 @@ func (MediaEXIF) TableName() string { func (exif *MediaEXIF) Media() *Media { panic("not implemented") } + +func (exif *MediaEXIF) Coordinates() *Coordinates { + if exif.GPSLatitude == nil || exif.GPSLongitude == nil { + return nil + } + + return &Coordinates{ + Latitude: *exif.GPSLatitude, + Longitude: *exif.GPSLongitude, + } +} 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/api/graphql/schema.graphql b/api/graphql/schema.graphql index 166b435..36a2df7 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -76,7 +76,7 @@ type Query { "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @isAuthorized "Get the mapbox api token, returns null if mapbox is not enabled" - mapboxToken: String @isAuthorized + mapboxToken: String shareToken(credentials: ShareTokenCredentials!): ShareToken! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! @@ -358,6 +358,15 @@ 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 +} + +type Coordinates { + "GPS latitude in degrees" + latitude: Float! + "GPS longitude in degrees" + longitude: Float! } type VideoMetadata { diff --git a/ui/i18next-parser.config.js b/ui/i18next-parser.config.js index f0f8670..c039225 100644 --- a/ui/i18next-parser.config.js +++ b/ui/i18next-parser.config.js @@ -1,7 +1,20 @@ module.exports = { skipDefaultValues: locale => locale != 'en', sort: true, - locales: ['da', 'de', 'en', 'es', 'fr', 'it', 'pl', 'ru', 'sv'], + locales: [ + 'da', + 'de', + 'en', + 'es', + 'fr', + 'it', + 'pl', + 'pt', + 'ru', + 'sv', + 'zh-CN', + 'zh-HK', + ], input: 'src/**/*.{js,ts,jsx,tsx}', output: 'src/extractedTranslations/$LOCALE/$NAMESPACE.json', } diff --git a/ui/package-lock.json b/ui/package-lock.json index dd55fed..deadfef 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,7 +12,7 @@ "@apollo/client": "^3.3.21", "@babel/preset-typescript": "^7.14.5", "@craco/craco": "^6.2.0", - "@headlessui/react": "^1.3.0", + "@headlessui/react": "^1.4.1", "@react-aria/focus": "^3.4.0", "@rollup/plugin-babel": "^5.3.0", "@types/geojson": "^7946.0.8", @@ -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" @@ -2102,9 +2103,9 @@ } }, "node_modules/@headlessui/react": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz", - "integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz", + "integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==", "engines": { "node": ">=10" }, @@ -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", @@ -28796,9 +28802,9 @@ } }, "@headlessui/react": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz", - "integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz", + "integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==", "requires": {} }, "@humanwhocodes/config-array": { @@ -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 ab0dbc6..5658733 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,7 @@ "@apollo/client": "^3.3.21", "@babel/preset-typescript": "^7.14.5", "@craco/craco": "^6.2.0", - "@headlessui/react": "^1.3.0", + "@headlessui/react": "^1.4.1", "@react-aria/focus": "^3.4.0", "@rollup/plugin-babel": "^5.3.0", "@types/geojson": "^7946.0.8", @@ -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" @@ -63,7 +64,7 @@ "lint:types": "tsc --noemit", "jest": "craco test --setupFilesAfterEnv ./testing/setupTests.ts", "jest:ci": "CI=true craco test --setupFilesAfterEnv ./testing/setupTests.ts --verbose --ci --coverage", - "genSchemaTypes": "apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts", + "genSchemaTypes": "apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts && prettier --write */**/__generated__/*.ts", "extractTranslations": "i18next -c i18next-parser.config.js", "prepare": "(cd .. && npx husky install)" }, @@ -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/Pages/PeoplePage/PeoplePage.test.tsx b/ui/src/Pages/PeoplePage/PeoplePage.test.tsx index 3a6e9b3..efef1f8 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.test.tsx +++ b/ui/src/Pages/PeoplePage/PeoplePage.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import PeoplePage, { FaceDetails, + FaceGroup, MY_FACES_QUERY, SET_GROUP_LABEL_MUTATION, } from './PeoplePage' @@ -144,7 +145,11 @@ describe('FaceDetails component', () => { render( - + ) @@ -159,7 +164,11 @@ describe('FaceDetails component', () => { render( - + ) @@ -190,7 +199,9 @@ describe('FaceDetails component', () => { ] render( - + + + ) @@ -211,4 +222,30 @@ describe('FaceDetails component', () => { expect(graphqlMocks[0].newData).toHaveBeenCalled() }) }) + + test('cancel add label to face group', async () => { + render( + + + + + + ) + + const btn = screen.getByRole('button') + expect(btn).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByText('Unlabeled')).toBeInTheDocument() + + fireEvent.click(btn) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('') + + fireEvent.change(input, { target: { value: 'John Doe' } }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }) + + expect(screen.queryByText('Unlabeled')).toBeInTheDocument() + }) }) diff --git a/ui/src/Pages/PeoplePage/PeoplePage.tsx b/ui/src/Pages/PeoplePage/PeoplePage.tsx index e60135d..79bab3c 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.tsx +++ b/ui/src/Pages/PeoplePage/PeoplePage.tsx @@ -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> } -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() @@ -126,11 +134,15 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => { if (!editLabel) { label = ( setEditLabel(true)} > {group.imageFaceCount} - {/* */} @@ -138,9 +150,9 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => { ) } else { label = ( - + { +export const FaceGroup = ({ group }: FaceGroupProps) => { const previewFace = group.imageFaces[0] + const [editLabel, setEditLabel] = useState(false) return (
- +
) } diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx index 2cd2b9b..221e8da 100644 --- a/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/DetachImageFacesModal.tsx @@ -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), }, { diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx index 48e2815..0e193be 100644 --- a/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/FaceGroupTitle.tsx @@ -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, + }, + ]} /> {
{title}
  • - +
  • - +
  • - +
  • - +
diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx index 455652a..1856e9c 100644 --- a/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/MergeFaceGroupsModal.tsx @@ -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(null) + const [selectedFaceGroup, setSelectedFaceGroup] = useState< + myFaces_myFaceGroups | singleFaceGroup_faceGroup | null + >(null) const history = useHistory() const { data } = useQuery(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 diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx b/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx index 6fc6494..4eee200 100644 --- a/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup/MoveImageFacesModal.tsx @@ -40,20 +40,26 @@ type MoveImageFacesModalProps = { open: boolean setOpen: React.Dispatch> 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(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(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) { diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx index 47d0eb8..4ec4423 100644 --- a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx +++ b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx @@ -52,14 +52,6 @@ const MapClusterMarker = ({ const thumbnail = JSON.parse(marker.thumbnail) const presentMedia = () => { - // presentMarkerClicked({ - // dispatchMedia: dispatchMarkerMedia, - // mediaState: markerMediaState, - // marker: { - // cluster: !!marker.cluster, - // id: marker.cluster ? marker.cluster_id : marker.media_id, - // }, - // }) dispatchMarkerMedia({ type: 'replacePresentMarker', marker: { @@ -67,10 +59,6 @@ const MapClusterMarker = ({ id: marker.cluster ? marker.cluster_id : marker.media_id, }, }) - // setPresentMarker({ - // cluster: !!marker.cluster, - // id: marker.cluster ? marker.cluster_id : marker.media_id, - // }) } return ( diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx index 2ffb15e..c4b4be4 100644 --- a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx +++ b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx @@ -80,6 +80,9 @@ type MapPresetMarkerProps = { dispatchMarkerMedia: React.Dispatch } +/** + * Full-screen present-view that works with PlacesState + */ const MapPresentMarker = ({ map, markerMediaState, diff --git a/ui/src/Pages/PlacesPage/PlacesPage.tsx b/ui/src/Pages/PlacesPage/PlacesPage.tsx index 1d15005..b41f432 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.tsx +++ b/ui/src/Pages/PlacesPage/PlacesPage.tsx @@ -1,30 +1,24 @@ import { gql, useQuery } from '@apollo/client' import type mapboxgl from 'mapbox-gl' -import React, { useEffect, useReducer, useRef, useState } from 'react' +import React, { useReducer } from 'react' import { Helmet } from 'react-helmet' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import Layout from '../../components/layout/Layout' -import { makeUpdateMarkers } from './mapboxHelperFunctions' -import MapPresentMarker from './MapPresentMarker' +import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions' +import useMapboxMap from '../../components/mapbox/MapboxMap' import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer' -import { placesReducer } from './placesReducer' - -import 'mapbox-gl/dist/mapbox-gl.css' +import MapPresentMarker from './MapPresentMarker' +import { PlacesAction, placesReducer } from './placesReducer' +import { mediaGeoJson } from './__generated__/mediaGeoJson' const MapWrapper = styled.div` width: 100%; height: calc(100vh - 120px); ` -const MapContainer = styled.div` - width: 100%; - height: 100%; -` - const MAPBOX_DATA_QUERY = gql` - query placePageMapboxToken { - mapboxToken + query mediaGeoJson { myMediaGeoJson } ` @@ -37,10 +31,9 @@ export type PresentMarker = { const MapPage = () => { const { t } = useTranslation() - const [mapboxLibrary, setMapboxLibrary] = useState() - const mapContainer = useRef(null) - const map = useRef(null) - // const [presentMarker, setPresentMarker] = useState(null) + const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY, { + fetchPolicy: 'cache-first', + }) const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, { presenting: false, @@ -48,76 +41,21 @@ const MapPage = () => { media: [], }) - const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY) + const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({ + configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }), + }) - useEffect(() => { - async function loadMapboxLibrary() { - const mapbox = (await import('mapbox-gl')).default - - setMapboxLibrary(mapbox) - } - loadMapboxLibrary() - }, []) - - useEffect(() => { - if ( - mapboxLibrary == null || - mapContainer.current == null || - mapboxData == null || - map.current != null - ) { - return - } - - mapboxLibrary.accessToken = mapboxData.mapboxToken - - map.current = new mapboxLibrary.Map({ - container: mapContainer.current, - style: 'mapbox://styles/mapbox/streets-v11', - zoom: 1, - }) - - // Add map navigation control - map.current.addControl(new mapboxLibrary.NavigationControl()) - - map.current.on('load', () => { - if (map.current == null) { - console.error('ERROR: map is null') - return - } - - map.current.addSource('media', { - type: 'geojson', - data: mapboxData.myMediaGeoJson, - cluster: true, - clusterRadius: 50, - clusterProperties: { - thumbnail: ['coalesce', ['get', 'thumbnail'], false], - }, + urlPresentModeSetupHook({ + dispatchMedia: dispatchMarkerMedia, + openPresentMode: event => { + dispatchMarkerMedia({ + type: 'openPresentMode', + activeIndex: event.state.activeIndex, }) + }, + }) - // Add dummy layer for features to be queryable - map.current.addLayer({ - id: 'media-points', - type: 'circle', - source: 'media', - filter: ['!', true], - }) - - const updateMarkers = makeUpdateMarkers({ - map: map.current, - mapboxLibrary, - dispatchMarkerMedia, - }) - - map.current.on('move', updateMarkers) - map.current.on('moveend', updateMarkers) - map.current.on('sourcedata', updateMarkers) - updateMarkers() - }) - }, [mapContainer, mapboxLibrary, mapboxData]) - - if (mapboxData && mapboxData.mapboxToken == null) { + if (mapboxData && mapboxToken == null) { return (

Mapbox token is not set

@@ -134,34 +72,64 @@ const MapPage = () => { ) } - urlPresentModeSetupHook({ - dispatchMedia: dispatchMarkerMedia, - openPresentMode: event => { - dispatchMarkerMedia({ - type: 'openPresentMode', - activeIndex: event.state.activeIndex, - }) - }, - }) - return ( {/* */} {/* */} - - - + {mapContainer} ) } +const configureMapbox = + ({ + mapboxData, + dispatchMarkerMedia, + }: { + mapboxData?: mediaGeoJson + dispatchMarkerMedia: React.Dispatch + }) => + (map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl) => { + // Add map navigation control + map.addControl(new mapboxLibrary.NavigationControl()) + + map.on('load', () => { + if (map == null) { + console.error('ERROR: map is null') + return + } + + map.addSource('media', { + type: 'geojson', + data: mapboxData?.myMediaGeoJson, + cluster: true, + clusterRadius: 50, + clusterProperties: { + thumbnail: ['coalesce', ['get', 'thumbnail'], false], + }, + }) + + // Add dummy layer for features to be queryable + map.addLayer({ + id: 'media-points', + type: 'circle', + source: 'media', + filter: ['!', true], + }) + + registerMediaMarkers({ + map: map, + mapboxLibrary, + dispatchMarkerMedia, + }) + }) + } + export default MapPage diff --git a/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts new file mode 100644 index 0000000..5f6d5da --- /dev/null +++ b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: mediaGeoJson +// ==================================================== + +export interface mediaGeoJson { + /** + * Get media owned by the logged in user, returned in GeoJson format + */ + myMediaGeoJson: any +} diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx deleted file mode 100644 index dad9147..0000000 --- a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type mapboxgl from 'mapbox-gl' -import type geojson from 'geojson' -import React from 'react' -import ReactDOM from 'react-dom' -import MapClusterMarker from './MapClusterMarker' -import { MediaMarker } from './MapPresentMarker' -import { PlacesAction } from './placesReducer' - -const markers: { [key: string]: mapboxgl.Marker } = {} -let markersOnScreen: typeof markers = {} - -type makeUpdateMarkersArgs = { - map: mapboxgl.Map - mapboxLibrary: typeof mapboxgl - dispatchMarkerMedia: React.Dispatch - // setPresentMarker: React.Dispatch> -} - -export const makeUpdateMarkers = ({ - map, - mapboxLibrary, - dispatchMarkerMedia, -}: makeUpdateMarkersArgs) => () => { - const newMarkers: typeof markers = {} - const features = map.querySourceFeatures('media') - - // for every media on the screen, create an HTML marker for it (if we didn't yet), - // and add it to the map if it's not there already - for (const feature of features) { - const point = feature.geometry as geojson.Point - const coords = point.coordinates as [number, number] - const props = feature.properties as MediaMarker - if (props == null) { - console.warn('WARN: geojson feature had no properties', feature) - continue - } - - const id = props.cluster - ? `cluster_${props.cluster_id}` - : `media_${props.media_id}` - - let marker = markers[id] - if (!marker) { - const el = createClusterPopupElement(props, { - dispatchMarkerMedia, - }) - marker = markers[id] = new mapboxLibrary.Marker({ - element: el, - }).setLngLat(coords) - } - newMarkers[id] = marker - - if (!markersOnScreen[id]) marker.addTo(map) - } - // for every marker we've added previously, remove those that are no longer visible - for (const id in markersOnScreen) { - if (!newMarkers[id]) markersOnScreen[id].remove() - } - markersOnScreen = newMarkers -} - -function createClusterPopupElement( - geojsonProps: MediaMarker, - { - dispatchMarkerMedia, - }: { - dispatchMarkerMedia: React.Dispatch - } -) { - // setPresentMarker: React.Dispatch> - const el = document.createElement('div') - ReactDOM.render( - , - el - ) - return el -} diff --git a/ui/src/Pages/SharePage/AlbumSharePage.tsx b/ui/src/Pages/SharePage/AlbumSharePage.tsx index 1549376..6635d12 100644 --- a/ui/src/Pages/SharePage/AlbumSharePage.tsx +++ b/ui/src/Pages/SharePage/AlbumSharePage.tsx @@ -66,6 +66,7 @@ export const SHARE_ALBUM_QUERY = gql` url } exif { + id camera maker lens @@ -76,6 +77,10 @@ export const SHARE_ALBUM_QUERY = gql` focalLength flash exposureProgram + coordinates { + latitude + longitude + } } } } diff --git a/ui/src/Pages/SharePage/MediaSharePage.tsx b/ui/src/Pages/SharePage/MediaSharePage.tsx index 366a790..e7a44e2 100644 --- a/ui/src/Pages/SharePage/MediaSharePage.tsx +++ b/ui/src/Pages/SharePage/MediaSharePage.tsx @@ -6,7 +6,7 @@ import { ProtectedVideo, } from '../../components/photoGallery/ProtectedMedia' import { SidebarContext } from '../../components/sidebar/Sidebar' -import MediaSidebar from '../../components/sidebar/MediaSidebar' +import MediaSidebar from '../../components/sidebar/MediaSidebar/MediaSidebar' import { useTranslation } from 'react-i18next' import { SharePageToken_shareToken_media } from './__generated__/SharePageToken' import { MediaType } from '../../__generated__/globalTypes' diff --git a/ui/src/Pages/SharePage/SharePage.tsx b/ui/src/Pages/SharePage/SharePage.tsx index e07d4c1..15ab360 100644 --- a/ui/src/Pages/SharePage/SharePage.tsx +++ b/ui/src/Pages/SharePage/SharePage.tsx @@ -59,6 +59,10 @@ export const SHARE_TOKEN_QUERY = gql` focalLength flash exposureProgram + coordinates { + longitude + latitude + } } } } diff --git a/ui/src/Pages/SharePage/__generated__/SharePageToken.ts b/ui/src/Pages/SharePage/__generated__/SharePageToken.ts index 0891fc7..e454c8b 100644 --- a/ui/src/Pages/SharePage/__generated__/SharePageToken.ts +++ b/ui/src/Pages/SharePage/__generated__/SharePageToken.ts @@ -3,172 +3,188 @@ // @generated // This file was automatically generated and should not be edited. -import { MediaType } from "./../../../__generated__/globalTypes"; +import { MediaType } from './../../../__generated__/globalTypes' // ==================================================== // GraphQL query operation: SharePageToken // ==================================================== export interface SharePageToken_shareToken_album { - __typename: "Album"; - id: string; + __typename: 'Album' + id: string } export interface SharePageToken_shareToken_media_thumbnail { - __typename: "MediaURL"; + __typename: 'MediaURL' /** * URL for previewing the image */ - url: string; + url: string /** * Width of the image in pixels */ - width: number; + width: number /** * Height of the image in pixels */ - height: number; + height: number } export interface SharePageToken_shareToken_media_downloads_mediaUrl { - __typename: "MediaURL"; + __typename: 'MediaURL' /** * URL for previewing the image */ - url: string; + url: string /** * Width of the image in pixels */ - width: number; + width: number /** * Height of the image in pixels */ - height: number; + height: number /** * The file size of the resource in bytes */ - fileSize: number; + fileSize: number } export interface SharePageToken_shareToken_media_downloads { - __typename: "MediaDownload"; - title: string; - mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl; + __typename: 'MediaDownload' + title: string + mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl } export interface SharePageToken_shareToken_media_highRes { - __typename: "MediaURL"; + __typename: 'MediaURL' /** * URL for previewing the image */ - url: string; + url: string /** * Width of the image in pixels */ - width: number; + width: number /** * Height of the image in pixels */ - height: number; + height: number } export interface SharePageToken_shareToken_media_videoWeb { - __typename: "MediaURL"; + __typename: 'MediaURL' /** * URL for previewing the image */ - url: string; + url: string /** * Width of the image in pixels */ - width: number; + width: number /** * Height of the image in pixels */ - height: number; + height: number +} + +export interface SharePageToken_shareToken_media_exif_coordinates { + __typename: 'Coordinates' + /** + * GPS longitude in degrees + */ + longitude: number + /** + * GPS latitude in degrees + */ + latitude: number } export interface SharePageToken_shareToken_media_exif { - __typename: "MediaEXIF"; - id: string; + __typename: 'MediaEXIF' + id: string /** * The model name of the camera */ - camera: string | null; + camera: string | null /** * The maker of the camera */ - maker: string | null; + maker: string | null /** * The name of the lens */ - lens: string | null; - dateShot: any | null; + lens: string | null + dateShot: any | null /** * The exposure time of the image */ - exposure: number | null; + exposure: number | null /** * The aperature stops of the image */ - aperture: number | null; + aperture: number | null /** * The ISO setting of the image */ - iso: number | null; + iso: number | null /** * The focal length of the lens, when the image was taken */ - focalLength: number | null; + focalLength: number | null /** * A formatted description of the flash settings, when the image was taken */ - flash: number | null; + flash: number | null /** * An index describing the mode for adjusting the exposure of the image */ - exposureProgram: number | null; + exposureProgram: number | null + /** + * GPS coordinates of where the image was taken + */ + coordinates: SharePageToken_shareToken_media_exif_coordinates | null } export interface SharePageToken_shareToken_media { - __typename: "Media"; - id: string; - title: string; - type: MediaType; + __typename: 'Media' + id: string + title: string + type: MediaType /** * URL to display the media in a smaller resolution */ - thumbnail: SharePageToken_shareToken_media_thumbnail | null; - downloads: SharePageToken_shareToken_media_downloads[]; + thumbnail: SharePageToken_shareToken_media_thumbnail | null + downloads: SharePageToken_shareToken_media_downloads[] /** * URL to display the photo in full resolution, will be null for videos */ - highRes: SharePageToken_shareToken_media_highRes | null; + highRes: SharePageToken_shareToken_media_highRes | null /** * URL to get the video in a web format that can be played in the browser, will be null for photos */ - videoWeb: SharePageToken_shareToken_media_videoWeb | null; - exif: SharePageToken_shareToken_media_exif | null; + videoWeb: SharePageToken_shareToken_media_videoWeb | null + exif: SharePageToken_shareToken_media_exif | null } export interface SharePageToken_shareToken { - __typename: "ShareToken"; - token: string; + __typename: 'ShareToken' + token: string /** * The album this token shares */ - album: SharePageToken_shareToken_album | null; + album: SharePageToken_shareToken_album | null /** * The media this token shares */ - media: SharePageToken_shareToken_media | null; + media: SharePageToken_shareToken_media | null } export interface SharePageToken { - shareToken: SharePageToken_shareToken; + shareToken: SharePageToken_shareToken } export interface SharePageTokenVariables { - token: string; - password?: string | null; + token: string + password?: string | null } diff --git a/ui/src/components/__generated__/albumPathQuery.ts b/ui/src/components/__generated__/albumPathQuery.ts index 258c586..6d141f5 100644 --- a/ui/src/components/__generated__/albumPathQuery.ts +++ b/ui/src/components/__generated__/albumPathQuery.ts @@ -8,15 +8,15 @@ // ==================================================== export interface albumPathQuery_album_path { - __typename: "Album"; - id: string; - title: string; + __typename: 'Album' + id: string + title: string } export interface albumPathQuery_album { - __typename: "Album"; - id: string; - path: albumPathQuery_album_path[]; + __typename: 'Album' + id: string + path: albumPathQuery_album_path[] } export interface albumPathQuery { @@ -24,9 +24,9 @@ export interface albumPathQuery { * Get album by id, user must own the album or be admin * If valid tokenCredentials are provided, the album may be retrived without further authentication */ - album: albumPathQuery_album; + album: albumPathQuery_album } export interface albumPathQueryVariables { - id: string; + id: string } 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/facesOverlay/FacesOverlay.tsx b/ui/src/components/facesOverlay/FacesOverlay.tsx index c38ed1d..d60066e 100644 --- a/ui/src/components/facesOverlay/FacesOverlay.tsx +++ b/ui/src/components/facesOverlay/FacesOverlay.tsx @@ -2,8 +2,8 @@ import React from 'react' import { Link } from 'react-router-dom' import styled from 'styled-components' import { MediaType } from '../../__generated__/globalTypes' -import { MediaSidebarMedia } from '../sidebar/MediaSidebar' -import { sidebarPhoto_media_faces } from '../sidebar/__generated__/sidebarPhoto' +import { MediaSidebarMedia } from '../sidebar/MediaSidebar/MediaSidebar' +import { sidebarMediaQuery_media_faces } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery' interface FaceBoxStyleProps { $minY: number @@ -23,7 +23,7 @@ const FaceBoxStyle = styled(Link)` ` type FaceBoxProps = { - face: sidebarPhoto_media_faces + face: sidebarMediaQuery_media_faces } const FaceBox = ({ face /*media*/ }: FaceBoxProps) => { diff --git a/ui/src/components/mapbox/MapboxMap.tsx b/ui/src/components/mapbox/MapboxMap.tsx new file mode 100644 index 0000000..071bd06 --- /dev/null +++ b/ui/src/components/mapbox/MapboxMap.tsx @@ -0,0 +1,80 @@ +import React, { useState, useRef, useEffect } from 'react' +import { gql, useQuery } from '@apollo/client' +import type mapboxgl from 'mapbox-gl' +import styled from 'styled-components' + +import 'mapbox-gl/dist/mapbox-gl.css' +import { mapboxToken } from './__generated__/mapboxToken' + +const MAPBOX_TOKEN_QUERY = gql` + query mapboxToken { + mapboxToken + myMediaGeoJson + } +` + +const MapContainer = styled.div` + width: 100%; + height: 100%; +` + +type MapboxMapProps = { + configureMapbox(map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl): void + mapboxOptions?: Partial +} + +const useMapboxMap = ({ + configureMapbox, + mapboxOptions = undefined, +}: MapboxMapProps) => { + const [mapboxLibrary, setMapboxLibrary] = useState() + const mapContainer = useRef(null) + const map = useRef(null) + + const { data: mapboxData } = useQuery(MAPBOX_TOKEN_QUERY, { + fetchPolicy: 'cache-first', + }) + + useEffect(() => { + async function loadMapboxLibrary() { + const mapbox = (await import('mapbox-gl')).default + + setMapboxLibrary(mapbox) + } + loadMapboxLibrary() + }, []) + + useEffect(() => { + if ( + mapboxLibrary == null || + mapContainer.current == null || + mapboxData == null || + map.current != null + ) { + return + } + + if (mapboxData.mapboxToken) + mapboxLibrary.accessToken = mapboxData.mapboxToken + + map.current = new mapboxLibrary.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v11', + ...mapboxOptions, + }) + + configureMapbox(map.current, mapboxLibrary) + map.current?.resize() + }, [mapContainer, mapboxLibrary, mapboxData]) + + map.current?.resize() + + return { + mapContainer: , + mapboxMap: map.current, + mapboxLibrary, + mapboxToken: mapboxData?.mapboxToken || null, + } +} + +export default useMapboxMap diff --git a/ui/src/components/mapbox/__generated__/mapboxToken.ts b/ui/src/components/mapbox/__generated__/mapboxToken.ts new file mode 100644 index 0000000..644bbae --- /dev/null +++ b/ui/src/components/mapbox/__generated__/mapboxToken.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: mapboxToken +// ==================================================== + +export interface mapboxToken { + /** + * Get the mapbox api token, returns null if mapbox is not enabled + */ + mapboxToken: string | null + /** + * Get media owned by the logged in user, returned in GeoJson format + */ + myMediaGeoJson: any +} diff --git a/ui/src/components/mapbox/mapboxHelperFunctions.tsx b/ui/src/components/mapbox/mapboxHelperFunctions.tsx new file mode 100644 index 0000000..6ea3a20 --- /dev/null +++ b/ui/src/components/mapbox/mapboxHelperFunctions.tsx @@ -0,0 +1,93 @@ +import type mapboxgl from 'mapbox-gl' +import type geojson from 'geojson' +import React from 'react' +import ReactDOM from 'react-dom' +import MapClusterMarker from '../../Pages/PlacesPage/MapClusterMarker' +import { MediaMarker } from '../../Pages/PlacesPage/MapPresentMarker' +import { PlacesAction } from '../../Pages/PlacesPage/placesReducer' + +const markers: { [key: string]: mapboxgl.Marker } = {} +let markersOnScreen: typeof markers = {} + +type registerMediaMarkersArgs = { + map: mapboxgl.Map + mapboxLibrary: typeof mapboxgl + dispatchMarkerMedia: React.Dispatch +} + +/** + * Add appropriate event handlers to the map, to render and update media markers + * Expects the provided mapbox map to contain geojson source of media + */ +export const registerMediaMarkers = (args: registerMediaMarkersArgs) => { + const updateMarkers = makeUpdateMarkers(args) + + args.map.on('move', updateMarkers) + args.map.on('moveend', updateMarkers) + args.map.on('sourcedata', updateMarkers) + updateMarkers() +} + +/** + * Make a function that can be passed to Mapbox to tell it how to render and update the image markers + */ +const makeUpdateMarkers = + ({ map, mapboxLibrary, dispatchMarkerMedia }: registerMediaMarkersArgs) => + () => { + const newMarkers: typeof markers = {} + const features = map.querySourceFeatures('media') + + // for every media on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (const feature of features) { + const point = feature.geometry as geojson.Point + const coords = point.coordinates as [number, number] + const props = feature.properties as MediaMarker + if (props == null) { + console.warn('WARN: geojson feature had no properties', feature) + continue + } + + const id = props.cluster + ? `cluster_${props.cluster_id}` + : `media_${props.media_id}` + + let marker = markers[id] + if (!marker) { + const el = createClusterPopupElement(props, { + dispatchMarkerMedia, + }) + marker = markers[id] = new mapboxLibrary.Marker({ + element: el, + }).setLngLat(coords) + } + newMarkers[id] = marker + + if (!markersOnScreen[id]) marker.addTo(map) + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove() + } + markersOnScreen = newMarkers + } + +function createClusterPopupElement( + geojsonProps: MediaMarker, + { + dispatchMarkerMedia, + }: { + dispatchMarkerMedia: React.Dispatch + } +) { + // setPresentMarker: React.Dispatch> + const el = document.createElement('div') + ReactDOM.render( + , + el + ) + return el +} diff --git a/ui/src/components/photoGallery/PhotoGallery.tsx b/ui/src/components/photoGallery/PhotoGallery.tsx index ba1f82e..3521f71 100644 --- a/ui/src/components/photoGallery/PhotoGallery.tsx +++ b/ui/src/components/photoGallery/PhotoGallery.tsx @@ -3,7 +3,6 @@ import styled from 'styled-components' import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail' import PresentView from './presentView/PresentView' import { PresentMediaProps_Media } from './presentView/PresentMedia' -import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto' import { openPresentModeAction, PhotoGalleryAction, @@ -13,8 +12,9 @@ import { toggleFavoriteAction, useMarkFavoriteMutation, } from './photoGalleryMutations' -import MediaSidebar from '../sidebar/MediaSidebar' +import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar' import { SidebarContext } from '../sidebar/Sidebar' +import { sidebarMediaQuery_media_thumbnail } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery' const Gallery = styled.div` display: flex; @@ -36,7 +36,7 @@ export const PhotoFiller = styled.div` ` export interface PhotoGalleryProps_Media extends PresentMediaProps_Media { - thumbnail: sidebarPhoto_media_thumbnail | null + thumbnail: sidebarMediaQuery_media_thumbnail | null favorite?: boolean } diff --git a/ui/src/components/routes/AuthorizedRoute.test.js b/ui/src/components/routes/AuthorizedRoute.test.tsx similarity index 84% rename from ui/src/components/routes/AuthorizedRoute.test.js rename to ui/src/components/routes/AuthorizedRoute.test.tsx index 7bd8371..7e87756 100644 --- a/ui/src/components/routes/AuthorizedRoute.test.js +++ b/ui/src/components/routes/AuthorizedRoute.test.tsx @@ -7,11 +7,15 @@ import * as authentication from '../../helpers/authentication' jest.mock('../../helpers/authentication.ts') +const authToken = authentication.authToken as jest.Mock< + ReturnType +> + describe('AuthorizedRoute component', () => { const AuthorizedComponent = () =>
authorized content
test('not logged in', async () => { - authentication.authToken.mockImplementation(() => null) + authToken.mockImplementation(() => null) render( @@ -24,7 +28,7 @@ describe('AuthorizedRoute component', () => { }) test('logged in', async () => { - authentication.authToken.mockImplementation(() => 'token-here') + authToken.mockImplementation(() => 'token-here') render( diff --git a/ui/src/components/sidebar/AlbumCovers.tsx b/ui/src/components/sidebar/AlbumCovers.tsx index d00cca7..4b0d7ab 100644 --- a/ui/src/components/sidebar/AlbumCovers.tsx +++ b/ui/src/components/sidebar/AlbumCovers.tsx @@ -12,6 +12,7 @@ import { resetAlbumCover, resetAlbumCoverVariables, } from './__generated__/resetAlbumCover' +import { authToken } from '../../helpers/authentication' const RESET_ALBUM_COVER_MUTATION = gql` mutation resetAlbumCover($albumID: ID!) { @@ -62,6 +63,11 @@ export const SidebarPhotoCover = ({ cover_id }: SidebarPhotoCoverProps) => { setButtonDisabled(false) }, [cover_id]) + // hide when not authenticated + if (!authToken()) { + return null + } + return ( diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebar.test.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.test.tsx new file mode 100644 index 0000000..6c0cf62 --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.test.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' +import MediaSidebar, { MediaSidebarMedia } from './MediaSidebar' +import { MediaType } from '../../../__generated__/globalTypes' +import { MemoryRouter } from 'react-router' + +import * as authentication from '../../../helpers/authentication' + +jest.mock('../../../helpers/authentication.ts') + +const authToken = authentication.authToken as jest.Mock< + ReturnType +> + +describe('MediaSidebar', () => { + const media: MediaSidebarMedia = { + __typename: 'Media', + id: '6867', + title: '122A6069.jpg', + type: MediaType.Photo, + thumbnail: { + __typename: 'MediaURL', + url: 'http://localhost:4001/photo/thumbnail.jpg', + width: 1024, + height: 839, + }, + highRes: { + __typename: 'MediaURL', + url: 'http://localhost:4001/photo/highres.jpg', + width: 5322, + height: 4362, + }, + videoWeb: null, + album: { + __typename: 'Album', + id: '2294', + title: 'album_name', + }, + } + + test('render sample image, unauthorized', () => { + authToken.mockImplementation(() => null) + + render( + + + + + + ) + + expect(screen.getByText('122A6069.jpg')).toBeInTheDocument() + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + 'http://localhost:4001/photo/highres.jpg' + ) + + expect( + screen.queryByText('Set as album cover photo') + ).not.toBeInTheDocument() + expect(screen.queryByText('Sharing options')).not.toBeInTheDocument() + }) + + test('render sample image, authorized', () => { + authToken.mockImplementation(() => 'token-here') + + render( + + + + + + ) + + expect(screen.getByText('122A6069.jpg')).toBeInTheDocument() + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + 'http://localhost:4001/photo/highres.jpg' + ) + + expect(screen.getByText('Set as album cover photo')).toBeInTheDocument() + expect(screen.getByText('Album path')).toBeInTheDocument() + + screen.debug() + }) +}) diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx new file mode 100644 index 0000000..fdadac8 --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebar.tsx @@ -0,0 +1,300 @@ +import { gql, useLazyQuery } from '@apollo/client' +import React, { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import styled from 'styled-components' +import { authToken } from '../../../helpers/authentication' +import { isNil } from '../../../helpers/utils' +import { MediaType } from '../../../__generated__/globalTypes' +import { SidebarFacesOverlay } from '../../facesOverlay/FacesOverlay' +import { + ProtectedImage, + ProtectedVideo, + ProtectedVideoProps_Media, +} from '../../photoGallery/ProtectedMedia' +import { SidebarPhotoCover } from '../AlbumCovers' +import { SidebarPhotoShare } from '../Sharing' +import SidebarMediaDownload from '../SidebarDownloadMedia' +import SidebarHeader from '../SidebarHeader' +import { sidebarDownloadQuery_media_downloads } from '../__generated__/sidebarDownloadQuery' +import ExifDetails from './MediaSidebarExif' +import MediaSidebarPeople from './MediaSidebarPeople' +import MediaSidebarMap from './MediaSidebarMap' +import { + sidebarMediaQuery, + sidebarMediaQueryVariables, + sidebarMediaQuery_media_album_path, + sidebarMediaQuery_media_exif, + sidebarMediaQuery_media_faces, + sidebarMediaQuery_media_thumbnail, + sidebarMediaQuery_media_videoMetadata, +} from './__generated__/sidebarMediaQuery' +import { BreadcrumbList } from '../../album/AlbumTitle' + +export const SIDEBAR_MEDIA_QUERY = gql` + query sidebarMediaQuery($id: ID!) { + media(id: $id) { + id + title + type + highRes { + url + width + height + } + thumbnail { + url + width + height + } + videoWeb { + url + width + height + } + videoMetadata { + id + width + height + duration + codec + framerate + bitrate + colorProfile + audio + } + exif { + id + camera + maker + lens + dateShot + exposure + aperture + iso + focalLength + flash + exposureProgram + coordinates { + latitude + longitude + } + } + album { + id + title + path { + id + title + } + } + faces { + id + rectangle { + minX + maxX + minY + maxY + } + faceGroup { + id + label + imageFaceCount + } + media { + id + title + thumbnail { + url + width + height + } + } + } + } + } +` + +const PreviewImage = styled(ProtectedImage)` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + object-fit: contain; +` + +const PreviewVideo = styled(ProtectedVideo)` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +` + +interface PreviewMediaPropsMedia extends ProtectedVideoProps_Media { + type: MediaType +} + +type PreviewMediaProps = { + media: PreviewMediaPropsMedia + previewImage?: { + url: string + } +} + +const PreviewMedia = ({ media, previewImage }: PreviewMediaProps) => { + if (media.type === MediaType.Photo) { + return + } + + if (media.type === MediaType.Video) { + return + } + + return
ERROR: Unknown media type: {media.type}
+} + +type SidebarContentProps = { + media: MediaSidebarMedia + hidePreview?: boolean +} + +const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => { + const { t } = useTranslation() + let previewImage = null + if (media.highRes) previewImage = media.highRes + else if (media.thumbnail) previewImage = media.thumbnail + + const imageAspect = + previewImage?.width && previewImage?.height + ? previewImage.height / previewImage.width + : 3 / 2 + + let sidebarMap = null + const mediaCoordinates = media.exif?.coordinates + if (mediaCoordinates) { + sidebarMap = + } + + let albumPath = null + const mediaAlbum = media.album + if (!isNil(mediaAlbum)) { + const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => ( +
  • + + {album.title} + +
  • + )) + + albumPath = ( +
    +

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

    + {pathElms} +
    + ) + } + + return ( +
    + +
    + {!hidePreview && ( +
    + + +
    + )} +
    + + {albumPath} + + {sidebarMap} + + +
    + +
    +
    + ) +} + +export interface MediaSidebarMedia { + __typename: 'Media' + id: string + title?: string + type: MediaType + highRes?: null | { + __typename: 'MediaURL' + url: string + width?: number + height?: number + } + thumbnail?: sidebarMediaQuery_media_thumbnail | null + videoWeb?: null | { + __typename: 'MediaURL' + url: string + width?: number + height?: number + } + videoMetadata?: sidebarMediaQuery_media_videoMetadata | null + exif?: sidebarMediaQuery_media_exif | null + faces?: sidebarMediaQuery_media_faces[] + downloads?: sidebarDownloadQuery_media_downloads[] + album?: { + __typename: 'Album' + id: string + title: string + path?: sidebarMediaQuery_media_album_path[] + } +} + +type MediaSidebarType = { + media: MediaSidebarMedia + hidePreview?: boolean +} + +const MediaSidebar = ({ media, hidePreview }: MediaSidebarType) => { + const [loadMedia, { loading, error, data }] = useLazyQuery< + sidebarMediaQuery, + sidebarMediaQueryVariables + >(SIDEBAR_MEDIA_QUERY) + + useEffect(() => { + if (media != null && authToken()) { + loadMedia({ + variables: { + id: media.id, + }, + }) + } + }, [media]) + + if (!media) return null + + if (!authToken()) { + return + } + + if (error) return
    {error.message}
    + + if (loading || data == null) { + return + } + + return +} + +export default MediaSidebar diff --git a/ui/src/components/sidebar/MediaSidebar.test.js b/ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.test.tsx similarity index 79% rename from ui/src/components/sidebar/MediaSidebar.test.js rename to ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.test.tsx index 7ec5f15..0e3b002 100644 --- a/ui/src/components/sidebar/MediaSidebar.test.js +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.test.tsx @@ -1,13 +1,15 @@ import React from 'react' import { render, screen } from '@testing-library/react' -import { MetadataInfo } from './MediaSidebar' +import ExifDetails from './MediaSidebarExif' +import { MediaSidebarMedia } from './MediaSidebar' +import { MediaType } from '../../../__generated__/globalTypes' -describe('MetadataInfo', () => { +describe('ExifDetails', () => { test('without EXIF information', async () => { - const media = { + const media: MediaSidebarMedia = { id: '1730', title: 'media_name.jpg', - type: 'Photo', + type: MediaType.Photo, exif: { id: '0', camera: null, @@ -20,12 +22,13 @@ describe('MetadataInfo', () => { focalLength: null, flash: null, exposureProgram: null, + coordinates: null, __typename: 'MediaEXIF', }, __typename: 'Media', } - render() + render() expect(screen.queryByText('Camera')).not.toBeInTheDocument() expect(screen.queryByText('Maker')).not.toBeInTheDocument() @@ -37,13 +40,14 @@ describe('MetadataInfo', () => { expect(screen.queryByText('ISO')).not.toBeInTheDocument() expect(screen.queryByText('Focal length')).not.toBeInTheDocument() expect(screen.queryByText('Flash')).not.toBeInTheDocument() + expect(screen.queryByText('Coordinates')).not.toBeInTheDocument() }) test('with EXIF information', async () => { - const media = { + const media: MediaSidebarMedia = { id: '1730', title: 'media_name.jpg', - type: 'Photo', + type: MediaType.Photo, exif: { id: '1666', camera: 'Canon EOS R', @@ -56,12 +60,17 @@ describe('MetadataInfo', () => { focalLength: 24, flash: 9, exposureProgram: 3, + coordinates: { + __typename: 'Coordinates', + latitude: 41.40338, + longitude: 2.17403, + }, __typename: 'MediaEXIF', }, __typename: 'Media', } - render() + render() expect(screen.getByText('Camera')).toBeInTheDocument() expect(screen.getByText('Canon EOS R')).toBeInTheDocument() @@ -94,5 +103,8 @@ describe('MetadataInfo', () => { expect(screen.getByText('Flash')).toBeInTheDocument() expect(screen.getByText('On, Fired')).toBeInTheDocument() + + expect(screen.getByText('Coordinates')).toBeInTheDocument() + expect(screen.getByText('41.40338, 2.17403')).toBeInTheDocument() }) }) diff --git a/ui/src/components/sidebar/MediaSidebar.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.tsx similarity index 57% rename from ui/src/components/sidebar/MediaSidebar.tsx rename to ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.tsx index 9f0b8a3..12adeaf 100644 --- a/ui/src/components/sidebar/MediaSidebar.tsx +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebarExif.tsx @@ -1,143 +1,20 @@ -import React, { useEffect } from 'react' -import { useLazyQuery, gql } from '@apollo/client' -import styled from 'styled-components' -import { authToken } from '../../helpers/authentication' -import { - ProtectedImage, - ProtectedVideo, - ProtectedVideoProps_Media, -} from '../photoGallery/ProtectedMedia' -import { SidebarPhotoShare } from './Sharing' -import SidebarMediaDownload from './SidebarDownloadMedia' -import SidebarItem from './SidebarItem' -import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay' -import { isNil } from '../../helpers/utils' +import React from 'react' import { useTranslation } from 'react-i18next' -import { MediaType } from '../../__generated__/globalTypes' -import { TranslationFn } from '../../localization' -import { - sidebarPhoto, - sidebarPhotoVariables, - sidebarPhoto_media_exif, - sidebarPhoto_media_faces, - sidebarPhoto_media_thumbnail, - sidebarPhoto_media_videoMetadata, -} from './__generated__/sidebarPhoto' - -import { sidebarDownloadQuery_media_downloads } from './__generated__/sidebarDownloadQuery' -import SidebarHeader from './SidebarHeader' -import { SidebarPhotoCover } from './AlbumCovers' - -const SIDEBAR_MEDIA_QUERY = gql` - query sidebarPhoto($id: ID!) { - media(id: $id) { - id - title - type - highRes { - url - width - height - } - thumbnail { - url - width - height - } - videoWeb { - url - width - height - } - videoMetadata { - id - width - height - duration - codec - framerate - bitrate - colorProfile - audio - } - exif { - id - camera - maker - lens - dateShot - exposure - aperture - iso - focalLength - flash - exposureProgram - } - faces { - id - rectangle { - minX - maxX - minY - maxY - } - faceGroup { - id - } - } - } - } -` - -const PreviewImage = styled(ProtectedImage)` - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - object-fit: contain; -` - -const PreviewVideo = styled(ProtectedVideo)` - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; -` - -interface PreviewMediaPropsMedia extends ProtectedVideoProps_Media { - type: MediaType -} - -type PreviewMediaProps = { - media: PreviewMediaPropsMedia - previewImage?: { - url: string - } -} - -const PreviewMedia = ({ media, previewImage }: PreviewMediaProps) => { - if (media.type === MediaType.Photo) { - return - } - - if (media.type === MediaType.Video) { - return - } - - return
    ERROR: Unknown media type: {media.type}
    -} +import styled from 'styled-components' +import { isNil } from '../../../helpers/utils' +import { TranslationFn } from '../../../localization' +import SidebarItem from '../SidebarItem' +import { MediaSidebarMedia } from './MediaSidebar' const MetadataInfoContainer = styled.div` margin-bottom: 1.5rem; ` -type MediaInfoProps = { +type ExifDetailsProps = { media?: MediaSidebarMedia } -export const MetadataInfo = ({ media }: MediaInfoProps) => { +const ExifDetails = ({ media }: ExifDetailsProps) => { const { t } = useTranslation() let exifItems: JSX.Element[] = [] @@ -170,6 +47,13 @@ export const MetadataInfo = ({ media }: MediaInfoProps) => { exif.exposure = `1/${Math.round(1 / exif.exposure)}` } + const coordinates = media.exif.coordinates + if (!isNil(coordinates)) { + exif.coordinates = `${ + Math.round(coordinates.latitude * 1000000) / 1000000 + }, ${Math.round(coordinates.longitude * 1000000) / 1000000}` + } + const exposurePrograms = exposureProgramsLookup(t) if ( @@ -246,6 +130,7 @@ const exifNameLookup = (t: TranslationFn): { [key: string]: string } => ({ iso: t('sidebar.media.exif.name.iso', 'ISO'), focalLength: t('sidebar.media.exif.name.focal_length', 'Focal length'), flash: t('sidebar.media.exif.name.flash', 'Flash'), + coordinates: t('sidebar.media.exif.name.coordinates', 'Coordinates'), }) // From https://exiftool.org/TagNames/EXIF.html @@ -331,106 +216,4 @@ const flashLookup = (t: TranslationFn): { [key: number]: string } => { } } -type SidebarContentProps = { - media: MediaSidebarMedia - hidePreview?: boolean -} - -const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => { - let previewImage = null - if (media.highRes) previewImage = media.highRes - else if (media.thumbnail) previewImage = media.thumbnail - - const imageAspect = - previewImage?.width && previewImage?.height - ? previewImage.height / previewImage.width - : 3 / 2 - - return ( -
    - -
    - {!hidePreview && ( -
    - - -
    - )} -
    - - - -
    - -
    -
    - ) -} - -export interface MediaSidebarMedia { - __typename: 'Media' - id: string - title?: string - type: MediaType - highRes?: null | { - __typename: 'MediaURL' - url: string - width?: number - height?: number - } - thumbnail?: sidebarPhoto_media_thumbnail | null - videoWeb?: null | { - __typename: 'MediaURL' - url: string - width?: number - height?: number - } - videoMetadata?: sidebarPhoto_media_videoMetadata | null - exif?: sidebarPhoto_media_exif | null - faces?: sidebarPhoto_media_faces[] - downloads?: sidebarDownloadQuery_media_downloads[] -} - -type MediaSidebarType = { - media: MediaSidebarMedia - hidePreview?: boolean -} - -const MediaSidebar = ({ media, hidePreview }: MediaSidebarType) => { - const [loadMedia, { loading, error, data }] = useLazyQuery< - sidebarPhoto, - sidebarPhotoVariables - >(SIDEBAR_MEDIA_QUERY) - - useEffect(() => { - if (media != null && authToken()) { - loadMedia({ - variables: { - id: media.id, - }, - }) - } - }, [media]) - - if (!media) return null - - if (!authToken()) { - return - } - - if (error) return
    {error.message}
    - - if (loading || data == null) { - return - } - - return -} - -export default MediaSidebar +export default ExifDetails diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebarMap.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebarMap.tsx new file mode 100644 index 0000000..1fa0f2c --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebarMap.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { isNil } from '../../../helpers/utils' +import useMapboxMap from '../../mapbox/MapboxMap' +import { SidebarSection, SidebarSectionTitle } from '../SidebarComponents' +import { sidebarMediaQuery_media_exif_coordinates } from './__generated__/sidebarMediaQuery' + +type MediaSidebarMapProps = { + coordinates: sidebarMediaQuery_media_exif_coordinates +} + +const MediaSidebarMap = ({ coordinates }: MediaSidebarMapProps) => { + const { t } = useTranslation() + + const { mapContainer, mapboxToken } = useMapboxMap({ + mapboxOptions: { + interactive: false, + zoom: 12, + center: { + lat: coordinates.latitude, + lng: coordinates.longitude, + }, + }, + configureMapbox: (map, mapboxLibrary) => { + // todo + map.addControl( + new mapboxLibrary.NavigationControl({ showCompass: false }) + ) + + const centerMarker = new mapboxLibrary.Marker({ + color: 'red', + scale: 0.8, + }) + centerMarker.setLngLat({ + lat: coordinates.latitude, + lng: coordinates.longitude, + }) + centerMarker.addTo(map) + }, + }) + + if (isNil(mapboxToken)) { + return null + } + + return ( + + + {t('sidebar.location.title', 'Location')} + +
    {mapContainer}
    +
    + ) +} + +export default MediaSidebarMap diff --git a/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx b/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx new file mode 100644 index 0000000..8b87488 --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/MediaSidebarPeople.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, useHistory } from 'react-router-dom' +import FaceCircleImage from '../../../Pages/PeoplePage/FaceCircleImage' +import { SidebarSection, SidebarSectionTitle } from '../SidebarComponents' +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 { 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, + onClick, +}: PersonMoreMenuItemProps) => { + return ( + + {({ active }) => ( + + )} + + ) +} + +type PersonMoreMenuProps = { + face: sidebarMediaQuery_media_faces + setChangeLabel: React.Dispatch> + className?: string + menuFlipped: boolean +} + +const PersonMoreMenu = ({ + menuFlipped, + face, + setChangeLabel, + className, +}: PersonMoreMenuProps) => { + const { t } = useTranslation() + + 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 = ( + <> + + + + ) + + 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 ( + <> + + + + + + + setChangeLabel(true)} + className="border-b" + label={t('people_page.action_label.change_label', 'Change label')} + /> + setMergeModalOpen(true)} + className="border-b" + label={t('sidebar.people.action_label.merge_face', 'Merge face')} + /> + detachImageFace()} + className="border-b" + label={t( + 'sidebar.people.action_label.detach_image', + 'Detach image' + )} + /> + setMoveModalOpen(true)} + label={t('sidebar.people.action_label.move_face', 'Move face')} + /> + + + + {modals} + + ) +} + +type MediaSidebarFaceProps = { + face: sidebarMediaQuery_media_faces + menuFlipped: boolean +} + +const MediaSidebarPerson = ({ face, menuFlipped }: MediaSidebarFaceProps) => { + const [changeLabel, setChangeLabel] = useState(false) + + return ( +
  • + + + +
    + + {!changeLabel && ( + + )} +
    +
  • + ) +} + +type MediaSidebarFacesProps = { + media: MediaSidebarMedia +} + +const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => { + const { t } = useTranslation() + + const faceElms = (media.faces ?? []).map((face, i) => ( + + )) + + if (faceElms.length == 0) return null + + return ( + + + {t('sidebar.people.title', 'People')} + +
    +
      {faceElms}
    +
    +
    +
    + ) +} + +export default MediaSidebarPeople diff --git a/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts b/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts new file mode 100644 index 0000000..13d8552 --- /dev/null +++ b/ui/src/components/sidebar/MediaSidebar/__generated__/sidebarMediaQuery.ts @@ -0,0 +1,229 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { MediaType } from './../../../../__generated__/globalTypes' + +// ==================================================== +// GraphQL query operation: sidebarMediaQuery +// ==================================================== + +export interface sidebarMediaQuery_media_highRes { + __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_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_videoWeb { + __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_videoMetadata { + __typename: 'VideoMetadata' + id: string + width: number + height: number + duration: number + codec: string | null + framerate: number | null + bitrate: string | null + colorProfile: string | null + audio: string | null +} + +export interface sidebarMediaQuery_media_exif_coordinates { + __typename: 'Coordinates' + /** + * GPS latitude in degrees + */ + latitude: number + /** + * GPS longitude in degrees + */ + longitude: number +} + +export interface sidebarMediaQuery_media_exif { + __typename: 'MediaEXIF' + id: string + /** + * The model name of the camera + */ + camera: string | null + /** + * The maker of the camera + */ + maker: string | null + /** + * The name of the lens + */ + lens: string | null + dateShot: any | null + /** + * The exposure time of the image + */ + exposure: number | null + /** + * The aperature stops of the image + */ + aperture: number | null + /** + * The ISO setting of the image + */ + iso: number | null + /** + * The focal length of the lens, when the image was taken + */ + focalLength: number | null + /** + * A formatted description of the flash settings, when the image was taken + */ + flash: number | null + /** + * An index describing the mode for adjusting the exposure of the image + */ + exposureProgram: number | null + /** + * GPS coordinates of where the image was taken + */ + 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 { + __typename: 'FaceRectangle' + minX: number + maxX: number + minY: number + maxY: number +} + +export interface sidebarMediaQuery_media_faces_faceGroup { + __typename: 'FaceGroup' + id: string + label: string | null + imageFaceCount: number +} + +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 { + __typename: 'Media' + id: string + title: string + type: MediaType + /** + * URL to display the photo in full resolution, will be null for videos + */ + highRes: sidebarMediaQuery_media_highRes | null + /** + * URL to display the media in a smaller resolution + */ + thumbnail: sidebarMediaQuery_media_thumbnail | null + /** + * URL to get the video in a web format that can be played in the browser, will be null for photos + */ + videoWeb: sidebarMediaQuery_media_videoWeb | null + videoMetadata: sidebarMediaQuery_media_videoMetadata | null + exif: sidebarMediaQuery_media_exif | null + /** + * The album that holds the media + */ + album: sidebarMediaQuery_media_album + faces: sidebarMediaQuery_media_faces[] +} + +export interface sidebarMediaQuery { + /** + * Get media by id, user must own the media or be admin. + * If valid tokenCredentials are provided, the media may be retrived without further authentication + */ + media: sidebarMediaQuery_media +} + +export interface sidebarMediaQueryVariables { + id: string +} 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 @@ + + + \ No newline at end of file diff --git a/ui/src/components/sidebar/Sharing.tsx b/ui/src/components/sidebar/Sharing.tsx index c2ccb80..04d3a29 100644 --- a/ui/src/components/sidebar/Sharing.tsx +++ b/ui/src/components/sidebar/Sharing.tsx @@ -13,7 +13,6 @@ import { sidebareDeleteShare, sidebareDeleteShareVariables, } from './__generated__/sidebareDeleteShare' -import { sidbarGetPhotoShares_media_shares } from './__generated__/sidbarGetPhotoShares' import { sidebarPhotoAddShare, sidebarPhotoAddShareVariables, @@ -25,6 +24,7 @@ import { import { sidebarGetPhotoShares, sidebarGetPhotoSharesVariables, + sidebarGetPhotoShares_media_shares, } from './__generated__/sidebarGetPhotoShares' import { sidebarGetAlbumShares, @@ -106,18 +106,38 @@ const DELETE_SHARE_MUTATION = gql` } ` -const ArrowPopoverPanel = styled.div.attrs({ +export const ArrowPopoverPanel = styled.div.attrs({ className: - 'absolute right-6 -top-3 bg-white rounded shadow-md border border-gray-200 w-[260px]', -})` + 'absolute -top-3 bg-white rounded shadow-md border border-gray-200 z-10', +})<{ width: number; flipped?: boolean }>` + width: ${({ width }) => width}px; + + ${({ flipped }) => + flipped + ? ` + left: 32px; + ` + : ` + right: 24px; + `} + &::after { content: ''; position: absolute; top: 18px; - right: -7px; width: 8px; height: 14px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 14'%3E%3Cpolyline stroke-width='1' stroke='%23E2E2E2' fill='%23FFFFFF' points='1 0 7 7 1 14'%3E%3C/polyline%3E%3C/svg%3E"); + + ${({ flipped }) => + flipped + ? ` + left: -7px; + transform: rotate(180deg); + ` + : ` + right: -7px; + `} } ` @@ -247,7 +267,7 @@ const MorePopover = ({ id, share, query }: MorePopoverProps) => { - +
    @@ -358,7 +378,7 @@ type SidebarShareProps = { id: string isPhoto: boolean loading: boolean - shares?: sidbarGetPhotoShares_media_shares[] + shares?: sidebarGetPhotoShares_media_shares[] shareItem(item: { variables: { id: string } }): void } diff --git a/ui/src/components/sidebar/SidebarDownloadMedia.tsx b/ui/src/components/sidebar/SidebarDownloadMedia.tsx index 261027b..01d4f5e 100644 --- a/ui/src/components/sidebar/SidebarDownloadMedia.tsx +++ b/ui/src/components/sidebar/SidebarDownloadMedia.tsx @@ -4,7 +4,7 @@ import { useLazyQuery, gql } from '@apollo/client' import { authToken } from '../../helpers/authentication' import { useTranslation } from 'react-i18next' import { TranslationFn } from '../../localization' -import { MediaSidebarMedia } from './MediaSidebar' +import { MediaSidebarMedia } from './MediaSidebar/MediaSidebar' import { sidebarDownloadQuery, sidebarDownloadQueryVariables, diff --git a/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts b/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts deleted file mode 100644 index f623c12..0000000 --- a/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -// ==================================================== -// GraphQL mutation operation: setAlbumCoverID -// ==================================================== - -export interface setAlbumCoverID_setAlbumCoverID { - __typename: 'Album' - id: string - coverID: string -} - -export interface setAlbumCoverID { - /** - * Assign a cover image to an album, set coverID to -1 to remove the current one - */ - setAlbumCoverID: setAlbumCoverID_setAlbumCoverID -} - -export interface setAlbumCoverIDVariables { - albumID: string - coverID: string -} diff --git a/ui/src/components/sidebar/__generated__/sidbarGetAlbumShares.ts b/ui/src/components/sidebar/__generated__/sidbarGetAlbumShares.ts deleted file mode 100644 index 7e7fa27..0000000 --- a/ui/src/components/sidebar/__generated__/sidbarGetAlbumShares.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -// ==================================================== -// GraphQL query operation: sidbarGetAlbumShares -// ==================================================== - -export interface sidbarGetAlbumShares_album_shares { - __typename: "ShareToken"; - id: string; - token: string; - /** - * Whether or not a password is needed to access the share - */ - hasPassword: boolean; -} - -export interface sidbarGetAlbumShares_album { - __typename: "Album"; - id: string; - shares: (sidbarGetAlbumShares_album_shares | null)[] | null; -} - -export interface sidbarGetAlbumShares { - /** - * Get album by id, user must own the album or be admin - * If valid tokenCredentials are provided, the album may be retrived without further authentication - */ - album: sidbarGetAlbumShares_album; -} - -export interface sidbarGetAlbumSharesVariables { - id: string; -} diff --git a/ui/src/components/sidebar/__generated__/sidbarGetPhotoShares.ts b/ui/src/components/sidebar/__generated__/sidbarGetPhotoShares.ts deleted file mode 100644 index 400e27d..0000000 --- a/ui/src/components/sidebar/__generated__/sidbarGetPhotoShares.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -// ==================================================== -// GraphQL query operation: sidbarGetPhotoShares -// ==================================================== - -export interface sidbarGetPhotoShares_media_shares { - __typename: "ShareToken"; - id: string; - token: string; - /** - * Whether or not a password is needed to access the share - */ - hasPassword: boolean; -} - -export interface sidbarGetPhotoShares_media { - __typename: "Media"; - id: string; - shares: sidbarGetPhotoShares_media_shares[]; -} - -export interface sidbarGetPhotoShares { - /** - * Get media by id, user must own the media or be admin. - * If valid tokenCredentials are provided, the media may be retrived without further authentication - */ - media: sidbarGetPhotoShares_media; -} - -export interface sidbarGetPhotoSharesVariables { - id: string; -} diff --git a/ui/src/components/sidebar/__generated__/sidebarPhoto.ts b/ui/src/components/sidebar/__generated__/sidebarPhoto.ts deleted file mode 100644 index 0dbad68..0000000 --- a/ui/src/components/sidebar/__generated__/sidebarPhoto.ts +++ /dev/null @@ -1,167 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @generated -// This file was automatically generated and should not be edited. - -import { MediaType } from "./../../../__generated__/globalTypes"; - -// ==================================================== -// GraphQL query operation: sidebarPhoto -// ==================================================== - -export interface sidebarPhoto_media_highRes { - __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 sidebarPhoto_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 sidebarPhoto_media_videoWeb { - __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 sidebarPhoto_media_videoMetadata { - __typename: "VideoMetadata"; - id: string; - width: number; - height: number; - duration: number; - codec: string | null; - framerate: number | null; - bitrate: string | null; - colorProfile: string | null; - audio: string | null; -} - -export interface sidebarPhoto_media_exif { - __typename: "MediaEXIF"; - id: string; - /** - * The model name of the camera - */ - camera: string | null; - /** - * The maker of the camera - */ - maker: string | null; - /** - * The name of the lens - */ - lens: string | null; - dateShot: any | null; - /** - * The exposure time of the image - */ - exposure: number | null; - /** - * The aperature stops of the image - */ - aperture: number | null; - /** - * The ISO setting of the image - */ - iso: number | null; - /** - * The focal length of the lens, when the image was taken - */ - focalLength: number | null; - /** - * A formatted description of the flash settings, when the image was taken - */ - flash: number | null; - /** - * An index describing the mode for adjusting the exposure of the image - */ - exposureProgram: number | null; -} - -export interface sidebarPhoto_media_faces_rectangle { - __typename: "FaceRectangle"; - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -export interface sidebarPhoto_media_faces_faceGroup { - __typename: "FaceGroup"; - id: string; -} - -export interface sidebarPhoto_media_faces { - __typename: "ImageFace"; - id: string; - rectangle: sidebarPhoto_media_faces_rectangle; - faceGroup: sidebarPhoto_media_faces_faceGroup; -} - -export interface sidebarPhoto_media { - __typename: "Media"; - id: string; - title: string; - type: MediaType; - /** - * URL to display the photo in full resolution, will be null for videos - */ - highRes: sidebarPhoto_media_highRes | null; - /** - * URL to display the media in a smaller resolution - */ - thumbnail: sidebarPhoto_media_thumbnail | null; - /** - * URL to get the video in a web format that can be played in the browser, will be null for photos - */ - videoWeb: sidebarPhoto_media_videoWeb | null; - videoMetadata: sidebarPhoto_media_videoMetadata | null; - exif: sidebarPhoto_media_exif | null; - faces: sidebarPhoto_media_faces[]; -} - -export interface sidebarPhoto { - /** - * Get media by id, user must own the media or be admin. - * If valid tokenCredentials are provided, the media may be retrived without further authentication - */ - media: sidebarPhoto_media; -} - -export interface sidebarPhotoVariables { - id: string; -} diff --git a/ui/src/components/timelineGallery/TimelineFilters.tsx b/ui/src/components/timelineGallery/TimelineFilters.tsx index 0884a27..1702550 100644 --- a/ui/src/components/timelineGallery/TimelineFilters.tsx +++ b/ui/src/components/timelineGallery/TimelineFilters.tsx @@ -53,7 +53,9 @@ const DateSelector = ({ filterDate, setFilterDate }: DateSelectorProps) => { const yearItems = years.map(x => ({ value: `${x}`, - label: `${x} and earlier`, + label: t('timeline_filter.date.dropdown_year', '{{year}} and earlier', { + year: x, + }), })) items = [...items, ...yearItems] } diff --git a/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx b/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx index c8b3743..7310e37 100644 --- a/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx +++ b/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx @@ -6,7 +6,7 @@ import { toggleFavoriteAction, useMarkFavoriteMutation, } from '../photoGallery/photoGalleryMutations' -import MediaSidebar from '../sidebar/MediaSidebar' +import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar' import { SidebarContext } from '../sidebar/Sidebar' import { getActiveTimelineImage, diff --git a/ui/src/extractedTranslations/da/translation.json b/ui/src/extractedTranslations/da/translation.json index fe49443..61a3949 100644 --- a/ui/src/extractedTranslations/da/translation.json +++ b/ui/src/extractedTranslations/da/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "Ændre navn", - "detach_face": "Løsriv billeder", - "merge_face": "Sammenflet personer", + "detach_images": "Løsriv billeder", + "merge_people": "Sammenflet personer", "move_faces": "Flyt ansigter" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Download" }, + "location": { + "title": "Lokation" + }, "media": { + "album_path": "Album sti", "exif": { "exposure_program": { "action_program": "Actionprogram", @@ -288,6 +292,7 @@ "name": { "aperture": "Blænde", "camera": "Kamera", + "coordinates": "Koordinater", "date_shot": "Dato", "exposure": "Lukketid", "exposure_program": "Lukketid program", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "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": { "add_share": "Tilføj deling", "copy_link": "Kopier link", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "Fra i dag", + "dropdown_year": "{{year}} og tidligere", "label": "Dato" } }, diff --git a/ui/src/extractedTranslations/da/translation_old.json b/ui/src/extractedTranslations/da/translation_old.json index a888751..ec51c67 100644 --- a/ui/src/extractedTranslations/da/translation_old.json +++ b/ui/src/extractedTranslations/da/translation_old.json @@ -42,6 +42,15 @@ "merge_face": null, "detach_face": null, "move_faces": null + }, + "modal": { + "merge_people_groups": { + "description": "Alle billeder fra denne gruppe vil blive flettet sammen med den valgte gruppe", + "destination_table": { + "title": "Vælg destinationsgruppe" + }, + "title": "Vælg gruppe at flette med" + } } }, "settings": { @@ -71,6 +80,9 @@ "mega_byte_plural": null, "tera_byte_plural": null } + }, + "media": { + "album": "Album" } }, "timeline_filter": { diff --git a/ui/src/extractedTranslations/de/translation.json b/ui/src/extractedTranslations/de/translation.json index ee94dd1..a5157e5 100644 --- a/ui/src/extractedTranslations/de/translation.json +++ b/ui/src/extractedTranslations/de/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Download" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Action (kurze Verschlusszeit)", @@ -288,6 +292,7 @@ "name": { "aperture": "Blende", "camera": "Kamera", + "coordinates": "", "date_shot": "Aufnahmedatum", "exposure": "Belichtung", "exposure_program": "Programm", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Freigabe hinzufügen", "copy_link": "Link kopieren", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/de/translation_old.json b/ui/src/extractedTranslations/de/translation_old.json index aa43241..d21d24f 100644 --- a/ui/src/extractedTranslations/de/translation_old.json +++ b/ui/src/extractedTranslations/de/translation_old.json @@ -61,6 +61,13 @@ "title": null }, "title": null + }, + "merge_people_groups": { + "description": "", + "destination_table": { + "title": "" + }, + "title": "" } }, "table": { @@ -115,6 +122,9 @@ "mega_byte_plural": null, "tera_byte_plural": null } + }, + "media": { + "album": "" } }, "album_filter": { diff --git a/ui/src/extractedTranslations/en/translation.json b/ui/src/extractedTranslations/en/translation.json index c64f063..791c7d2 100644 --- a/ui/src/extractedTranslations/en/translation.json +++ b/ui/src/extractedTranslations/en/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "Change label", - "detach_face": "Detach face", - "merge_face": "Merge face", + "detach_images": "Detach face", + "merge_people": "Merge face", "move_faces": "Move faces" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Download" }, + "location": { + "title": "Location" + }, "media": { + "album_path": "Album path", "exif": { "exposure_program": { "action_program": "Action program", @@ -288,6 +292,7 @@ "name": { "aperture": "Aperture", "camera": "Camera", + "coordinates": "Coordinates", "date_shot": "Date shot", "exposure": "Exposure", "exposure_program": "Program", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "Detach image", + "merge_face": "Merge face", + "move_face": "Move face" + }, + "confirm_image_detach": "Are you sure you want to detach this image?", + "title": "People" + }, "sharing": { "add_share": "Add shares", "copy_link": "Copy Link", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "From today", + "dropdown_year": "{{year}} and earlier", "label": "Date" } }, diff --git a/ui/src/extractedTranslations/en/translation_old.json b/ui/src/extractedTranslations/en/translation_old.json index fecc4be..1a50360 100644 --- a/ui/src/extractedTranslations/en/translation_old.json +++ b/ui/src/extractedTranslations/en/translation_old.json @@ -36,6 +36,15 @@ "select_image_faces": { "search_images_placeholder": "Search images..." } + }, + "modal": { + "merge_people_groups": { + "description": "All images within this face group will be merged into the selected face group.", + "destination_table": { + "title": "Select the destination face" + }, + "title": "Merge Face Groups" + } } }, "settings": { @@ -54,6 +63,9 @@ }, "sharing": { "table_header": "Public shares" + }, + "media": { + "album": "Album" } } } diff --git a/ui/src/extractedTranslations/es/translation.json b/ui/src/extractedTranslations/es/translation.json index 7d1d0b7..8003028 100644 --- a/ui/src/extractedTranslations/es/translation.json +++ b/ui/src/extractedTranslations/es/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Descargar" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Programa de acción", @@ -288,6 +292,7 @@ "name": { "aperture": "Abertura", "camera": "Cámara", + "coordinates": "", "date_shot": "Fecha de captura", "exposure": "Exposición", "exposure_program": "Programa", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Añadir compartido", "copy_link": "Copiar enlace", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/es/translation_old.json b/ui/src/extractedTranslations/es/translation_old.json index 94a8bb3..eee880e 100644 --- a/ui/src/extractedTranslations/es/translation_old.json +++ b/ui/src/extractedTranslations/es/translation_old.json @@ -61,6 +61,13 @@ "title": null }, "title": null + }, + "merge_people_groups": { + "description": "", + "destination_table": { + "title": "" + }, + "title": "" } }, "table": { @@ -120,6 +127,9 @@ "mega_byte_plural": null, "tera_byte_plural": null } + }, + "media": { + "album": "" } }, "title": { diff --git a/ui/src/extractedTranslations/fr/translation.json b/ui/src/extractedTranslations/fr/translation.json index 64eb30c..51ca9cd 100644 --- a/ui/src/extractedTranslations/fr/translation.json +++ b/ui/src/extractedTranslations/fr/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "Changer le label", - "detach_face": "Détacher le visage", - "merge_face": "Fusionner le visage", + "detach_images": "Détacher le visage", + "merge_people": "Fusionner le visage", "move_faces": "Déplacer les visages" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Télécharger" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Programme d'action", @@ -288,6 +292,7 @@ "name": { "aperture": "Ouverture", "camera": "Modèle", + "coordinates": "", "date_shot": "Prise de vue", "exposure": "Vitesse", "exposure_program": "Programme", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Ajouter un partage", "copy_link": "Copier le lien", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "Depuis aujourd'hui", + "dropdown_year": "", "label": "Date" } }, diff --git a/ui/src/extractedTranslations/fr/translation_old.json b/ui/src/extractedTranslations/fr/translation_old.json index 61e3c86..58420ff 100644 --- a/ui/src/extractedTranslations/fr/translation_old.json +++ b/ui/src/extractedTranslations/fr/translation_old.json @@ -60,6 +60,13 @@ "title": null }, "title": null + }, + "merge_people_groups": { + "description": "Toutes les images de ce groupe de visages seront fusionnées dans le groupe de visages sélectionné.", + "destination_table": { + "title": "Sélectionner le visage de destination" + }, + "title": "Fusionner des groupes de visages" } }, "table": { @@ -95,6 +102,9 @@ }, "sharing": { "table_header": "Partages publics" + }, + "media": { + "album": "" } }, "title": { diff --git a/ui/src/extractedTranslations/it/translation.json b/ui/src/extractedTranslations/it/translation.json index b64f070..528d835 100644 --- a/ui/src/extractedTranslations/it/translation.json +++ b/ui/src/extractedTranslations/it/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Download" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Programma sport", @@ -288,6 +292,7 @@ "name": { "aperture": "Apertura", "camera": "Fotocamera", + "coordinates": "", "date_shot": "Data di scatto", "exposure": "Esposizione", "exposure_program": "Programma", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Aggiungi condivisione", "copy_link": "Copia il link", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/it/translation_old.json b/ui/src/extractedTranslations/it/translation_old.json index b09cdcc..3cd12dd 100644 --- a/ui/src/extractedTranslations/it/translation_old.json +++ b/ui/src/extractedTranslations/it/translation_old.json @@ -49,6 +49,15 @@ }, "tableselect_image_faces": { "search_images_placeholder": null + }, + "modal": { + "merge_people_groups": { + "description": "Tutte le immagini di questo volto saranno unite alle immagini del volto selezionato", + "destination_table": { + "title": "Seleziona il volto di destinazione" + }, + "title": "Unisci immagini volto" + } } }, "settings": { @@ -82,6 +91,9 @@ "mega_byte_plural": null, "tera_byte_plural": null } + }, + "media": { + "album": "" } }, "album_filter": { diff --git a/ui/src/extractedTranslations/pl/translation.json b/ui/src/extractedTranslations/pl/translation.json index 962ab26..8e7186f 100644 --- a/ui/src/extractedTranslations/pl/translation.json +++ b/ui/src/extractedTranslations/pl/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -264,7 +264,11 @@ }, "title": "Pobierz" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Program działania", @@ -293,6 +297,7 @@ "name": { "aperture": "Przysłona", "camera": "Aparat ", + "coordinates": "", "date_shot": "Data wykonania", "exposure": "Ekspozycja", "exposure_program": "Program", @@ -304,6 +309,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Dodaj udział", "copy_link": "Skopiuj link", @@ -324,6 +338,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/pl/translation_old.json b/ui/src/extractedTranslations/pl/translation_old.json index 19305fd..fab4374 100644 --- a/ui/src/extractedTranslations/pl/translation_old.json +++ b/ui/src/extractedTranslations/pl/translation_old.json @@ -61,6 +61,13 @@ "title": null }, "title": null + }, + "merge_people_groups": { + "description": "", + "destination_table": { + "title": "" + }, + "title": "" } }, "table": { @@ -137,6 +144,9 @@ "table_header": "Publiczne udostępnienia", "delete": null, "more": null + }, + "media": { + "album": "" } }, "title": { diff --git a/ui/src/extractedTranslations/pt/translation.json b/ui/src/extractedTranslations/pt/translation.json index 358d159..7e90e82 100644 --- a/ui/src/extractedTranslations/pt/translation.json +++ b/ui/src/extractedTranslations/pt/translation.json @@ -61,17 +61,17 @@ "description": "Galeria de fotos simples e fácil de usar para servidores pessoais" }, "people_page": { + "action_label": { + "change_label": "", + "detach_images": "", + "merge_people": "", + "move_faces": "" + }, "face_group": { "label_placeholder": "Etiqueta", "unlabeled": "Sem etiqueta", "unlabeled_person": "Pessoa sem etiqueta" }, - "action_label": { - "change_label": null, - "merge_face": null, - "detach_face": null, - "move_faces": null - }, "modal": { "action": { "merge": "Juntar" @@ -215,6 +215,27 @@ }, "sidebar": { "album": { + "album_cover": "", + "download": { + "high-resolutions": { + "description": "", + "title": "" + }, + "originals": { + "description": "", + "title": "" + }, + "thumbnails": { + "description": "", + "title": "" + }, + "web-videos": { + "description": "", + "title": "" + } + }, + "reset_cover": "", + "set_cover": "", "title_placeholder": "Título do Álbum" }, "download": { @@ -238,7 +259,11 @@ }, "title": "Descarregar" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Programa de ação", @@ -267,6 +292,7 @@ "name": { "aperture": "Abertura", "camera": "Câmera", + "coordinates": "", "date_shot": "Data da foto", "exposure": "Exposição", "exposure_program": "Programa", @@ -278,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Adicionar partilha", "copy_link": "Copiar link", @@ -295,6 +330,13 @@ "places": "Locais", "settings": "Configurações" }, + "timeline_filter": { + "date": { + "dropdown_all": "", + "dropdown_year": "", + "label": "" + } + }, "title": { "loading_album": "Carregar Álbum", "login": "Login", diff --git a/ui/src/extractedTranslations/pt/translation_old.json b/ui/src/extractedTranslations/pt/translation_old.json new file mode 100644 index 0000000..8f73fa0 --- /dev/null +++ b/ui/src/extractedTranslations/pt/translation_old.json @@ -0,0 +1,19 @@ +{ + "people_page": { + "action_label": { + "change_label": null, + "merge_people": null, + "detach_images": null, + "move_faces": null + }, + "modal": { + "merge_people_groups": { + "description": "Todas as imagens neste grupo de caras serão juntas no grupo selecionado.", + "destination_table": { + "title": "Selecione a cara de destino" + }, + "title": "Juntar grupos de cara" + } + } + } +} diff --git a/ui/src/extractedTranslations/ru/translation.json b/ui/src/extractedTranslations/ru/translation.json index d6b1530..2665ec8 100644 --- a/ui/src/extractedTranslations/ru/translation.json +++ b/ui/src/extractedTranslations/ru/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -264,7 +264,11 @@ }, "title": "Скачать" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Действие программа", @@ -293,6 +297,7 @@ "name": { "aperture": "Диафрагма", "camera": "Камера", + "coordinates": "", "date_shot": "Дата снимка", "exposure": "Экспозиция", "exposure_program": "Программа", @@ -304,6 +309,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Поделится", "copy_link": "Скопировать ссылку", @@ -324,6 +338,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/ru/translation_old.json b/ui/src/extractedTranslations/ru/translation_old.json index 9ade823..ae7cdd0 100644 --- a/ui/src/extractedTranslations/ru/translation_old.json +++ b/ui/src/extractedTranslations/ru/translation_old.json @@ -49,6 +49,15 @@ }, "tableselect_image_faces": { "search_images_placeholder": null + }, + "modal": { + "merge_people_groups": { + "description": "Все фотографии в этой группе лиц будут обьеденены с выбранной группой.", + "destination_table": { + "title": "Выберете конечное лицо" + }, + "title": "Обьединить группы лиц" + } } }, "settings": { @@ -99,6 +108,9 @@ "table_header": "Общий доступ", "delete": null, "more": null + }, + "media": { + "album": "" } }, "album_filter": { diff --git a/ui/src/extractedTranslations/sv/translation.json b/ui/src/extractedTranslations/sv/translation.json index 19c1aae..2fcfeaa 100644 --- a/ui/src/extractedTranslations/sv/translation.json +++ b/ui/src/extractedTranslations/sv/translation.json @@ -63,8 +63,8 @@ "people_page": { "action_label": { "change_label": "", - "detach_face": "", - "merge_face": "", + "detach_images": "", + "merge_people": "", "move_faces": "" }, "face_group": { @@ -259,7 +259,11 @@ }, "title": "Ladda ner" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "Actionprogram", @@ -288,6 +292,7 @@ "name": { "aperture": "Bländare", "camera": "Kamera", + "coordinates": "", "date_shot": "Datum tagen", "exposure": "Exponering", "exposure_program": "Program", @@ -299,6 +304,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "Dela", "copy_link": "Kopiera länk", @@ -319,6 +333,7 @@ "timeline_filter": { "date": { "dropdown_all": "", + "dropdown_year": "", "label": "" } }, diff --git a/ui/src/extractedTranslations/sv/translation_old.json b/ui/src/extractedTranslations/sv/translation_old.json index fb0943d..061609f 100644 --- a/ui/src/extractedTranslations/sv/translation_old.json +++ b/ui/src/extractedTranslations/sv/translation_old.json @@ -61,6 +61,13 @@ "title": null }, "title": null + }, + "merge_people_groups": { + "description": "", + "destination_table": { + "title": "" + }, + "title": "" } }, "table": { @@ -123,6 +130,9 @@ "mega_byte_plural": null, "tera_byte_plural": null } + }, + "media": { + "album": "" } }, "title": { diff --git a/ui/src/extractedTranslations/zh-CN/translation.json b/ui/src/extractedTranslations/zh-CN/translation.json index c60569f..d17e811 100644 --- a/ui/src/extractedTranslations/zh-CN/translation.json +++ b/ui/src/extractedTranslations/zh-CN/translation.json @@ -61,17 +61,17 @@ "description": "简单及易用的照片库给个人服务器" }, "people_page": { + "action_label": { + "change_label": "", + "detach_images": "", + "merge_people": "", + "move_faces": "" + }, "face_group": { "label_placeholder": "名称", "unlabeled": "未命名", "unlabeled_person": "未命名人物" }, - "action_label": { - "change_label": null, - "merge_face": null, - "detach_face": null, - "move_faces": null - }, "modal": { "action": { "merge": "合并" @@ -215,30 +215,36 @@ }, "sidebar": { "album": { + "album_cover": "", + "download": { + "high-resolutions": { + "description": "", + "title": "" + }, + "originals": { + "description": "", + "title": "" + }, + "thumbnails": { + "description": "", + "title": "" + }, + "web-videos": { + "description": "", + "title": "" + } + }, + "reset_cover": "", + "set_cover": "", "title_placeholder": "相册名称" }, "download": { "filesize": { - "byte": null, - "giga_byte": null, - "kilo_byte": null, - "mega_byte": null, - "tera_byte": null, - "byte_0": null, - "byte_1": null, - "byte_2": null, - "giga_byte_0": null, - "giga_byte_1": null, - "giga_byte_2": null, - "kilo_byte_0": null, - "kilo_byte_1": null, - "kilo_byte_2": null, - "mega_byte_0": null, - "mega_byte_1": null, - "mega_byte_2": null, - "tera_byte_0": null, - "tera_byte_1": null, - "tera_byte_2": null + "byte": "", + "giga_byte": "", + "kilo_byte": "", + "mega_byte": "", + "tera_byte": "" }, "table_columns": { "dimensions": "尺寸", @@ -248,7 +254,11 @@ }, "title": "下载" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "动态模式", @@ -264,19 +274,20 @@ }, "flash": { "auto": "自动", - "did_not_fire": null, - "fired": null, - "no_flash": null, - "no_flash_function": null, + "did_not_fire": "", + "fired": "", + "no_flash": "", + "no_flash_function": "", "off": "关闭", "on": "使用", "red_eye_reduction": "减轻红眼", - "return_detected": null, - "return_not_detected": null + "return_detected": "", + "return_not_detected": "" }, "name": { "aperture": "光圈", "camera": "相机", + "coordinates": "", "date_shot": "拍摄日期", "exposure": "曝光", "exposure_program": "模式", @@ -288,6 +299,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "新增分享", "copy_link": "复制链接", @@ -305,6 +325,13 @@ "places": "地点", "settings": "设置" }, + "timeline_filter": { + "date": { + "dropdown_all": "", + "dropdown_year": "", + "label": "" + } + }, "title": { "loading_album": "载入相册", "login": "登入", diff --git a/ui/src/extractedTranslations/zh-CN/translation_old.json b/ui/src/extractedTranslations/zh-CN/translation_old.json new file mode 100644 index 0000000..1f14223 --- /dev/null +++ b/ui/src/extractedTranslations/zh-CN/translation_old.json @@ -0,0 +1,42 @@ +{ + "people_page": { + "action_label": { + "change_label": null, + "merge_people": null, + "detach_images": null, + "move_faces": null + }, + "modal": { + "merge_people_groups": { + "description": "所有在此的脸孔群组将会合并到已选择的脸孔群组", + "destination_table": { + "title": "选择目标脸孔群组" + }, + "title": "合并脸孔群组" + } + } + }, + "sidebar": { + "download": { + "filesize": { + "byte": null, + "giga_byte": null, + "kilo_byte": null, + "mega_byte": null, + "tera_byte": null + } + }, + "media": { + "exif": { + "flash": { + "did_not_fire": null, + "fired": null, + "no_flash": null, + "no_flash_function": null, + "return_detected": null, + "return_not_detected": null + } + } + } + } +} diff --git a/ui/src/extractedTranslations/zh-HK/translation.json b/ui/src/extractedTranslations/zh-HK/translation.json index aa8fc83..479cc16 100644 --- a/ui/src/extractedTranslations/zh-HK/translation.json +++ b/ui/src/extractedTranslations/zh-HK/translation.json @@ -61,17 +61,17 @@ "description": "簡單及易用的照片庫給個人伺服器" }, "people_page": { + "action_label": { + "change_label": "", + "detach_images": "", + "merge_people": "", + "move_faces": "" + }, "face_group": { "label_placeholder": "名稱", "unlabeled": "未命名", "unlabeled_person": "未命名人物" }, - "action_label": { - "change_label": null, - "merge_face": null, - "detach_face": null, - "move_faces": null - }, "modal": { "action": { "merge": "合併" @@ -215,30 +215,36 @@ }, "sidebar": { "album": { + "album_cover": "", + "download": { + "high-resolutions": { + "description": "", + "title": "" + }, + "originals": { + "description": "", + "title": "" + }, + "thumbnails": { + "description": "", + "title": "" + }, + "web-videos": { + "description": "", + "title": "" + } + }, + "reset_cover": "", + "set_cover": "", "title_placeholder": "相簿名稱" }, "download": { "filesize": { - "byte": null, - "giga_byte": null, - "kilo_byte": null, - "mega_byte": null, - "tera_byte": null, - "byte_0": null, - "byte_1": null, - "byte_2": null, - "giga_byte_0": null, - "giga_byte_1": null, - "giga_byte_2": null, - "kilo_byte_0": null, - "kilo_byte_1": null, - "kilo_byte_2": null, - "mega_byte_0": null, - "mega_byte_1": null, - "mega_byte_2": null, - "tera_byte_0": null, - "tera_byte_1": null, - "tera_byte_2": null + "byte": "", + "giga_byte": "", + "kilo_byte": "", + "mega_byte": "", + "tera_byte": "" }, "table_columns": { "dimensions": "尺寸", @@ -248,7 +254,11 @@ }, "title": "下載" }, + "location": { + "title": "" + }, "media": { + "album_path": "", "exif": { "exposure_program": { "action_program": "動態模式", @@ -264,19 +274,20 @@ }, "flash": { "auto": "自動", - "did_not_fire": null, - "fired": null, - "no_flash": null, - "no_flash_function": null, + "did_not_fire": "", + "fired": "", + "no_flash": "", + "no_flash_function": "", "off": "關閉", "on": "使用", "red_eye_reduction": "減輕紅眼", - "return_detected": null, - "return_not_detected": null + "return_detected": "", + "return_not_detected": "" }, "name": { "aperture": "光圈", "camera": "相機", + "coordinates": "", "date_shot": "拍攝日期", "exposure": "曝光", "exposure_program": "模式", @@ -288,6 +299,15 @@ } } }, + "people": { + "action_label": { + "detach_image": "", + "merge_face": "", + "move_face": "" + }, + "confirm_image_detach": "", + "title": "" + }, "sharing": { "add_share": "新增分享", "copy_link": "複製連結", @@ -305,6 +325,13 @@ "places": "地點", "settings": "設定" }, + "timeline_filter": { + "date": { + "dropdown_all": "", + "dropdown_year": "", + "label": "" + } + }, "title": { "loading_album": "載入相簿", "login": "登入", diff --git a/ui/src/extractedTranslations/zh-HK/translation_old.json b/ui/src/extractedTranslations/zh-HK/translation_old.json new file mode 100644 index 0000000..c45a0a1 --- /dev/null +++ b/ui/src/extractedTranslations/zh-HK/translation_old.json @@ -0,0 +1,42 @@ +{ + "people_page": { + "action_label": { + "change_label": null, + "merge_people": null, + "detach_images": null, + "move_faces": null + }, + "modal": { + "merge_people_groups": { + "description": "所有在此的臉孔群組將會合併到已選擇的臉孔群組", + "destination_table": { + "title": "選擇目標臉孔群組" + }, + "title": "合併臉孔群組" + } + } + }, + "sidebar": { + "download": { + "filesize": { + "byte": null, + "giga_byte": null, + "kilo_byte": null, + "mega_byte": null, + "tera_byte": null + } + }, + "media": { + "exif": { + "flash": { + "did_not_fire": null, + "fired": null, + "no_flash": null, + "no_flash_function": null, + "return_detected": null, + "return_not_detected": null + } + } + } + } +} diff --git a/ui/src/helpers/utils.ts b/ui/src/helpers/utils.ts index 8879ea3..8bf66d7 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,8 @@ 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)) + return 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) => ( ) => (