1
Fork 0

Merge pull request #554 from photoview/viktorstrate/issue319

Add faces and map to media sidebar
This commit is contained in:
Viktor Strate Kløvedal 2021-10-21 18:11:22 +02:00 committed by GitHub
commit 72cb02d981
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 2346 additions and 974 deletions

View File

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

View File

@ -75,6 +75,11 @@ type ComplexityRoot struct {
Token func(childComplexity int) int Token func(childComplexity int) int
} }
Coordinates struct {
Latitude func(childComplexity int) int
Longitude func(childComplexity int) int
}
FaceGroup struct { FaceGroup struct {
ID func(childComplexity int) int ID func(childComplexity int) int
ImageFaceCount func(childComplexity int) int ImageFaceCount func(childComplexity int) int
@ -122,6 +127,7 @@ type ComplexityRoot struct {
MediaExif struct { MediaExif struct {
Aperture func(childComplexity int) int Aperture func(childComplexity int) int
Camera func(childComplexity int) int Camera func(childComplexity int) int
Coordinates func(childComplexity int) int
DateShot func(childComplexity int) int DateShot func(childComplexity int) int
Exposure func(childComplexity int) int Exposure func(childComplexity int) int
ExposureProgram 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) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error)
} }
type ImageFaceResolver interface { type ImageFaceResolver interface {
Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error)
FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error)
} }
type MediaResolver interface { type MediaResolver interface {
@ -473,6 +481,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.AuthorizeResult.Token(childComplexity), true 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": case "FaceGroup.id":
if e.complexity.FaceGroup.ID == nil { if e.complexity.FaceGroup.ID == nil {
break break
@ -695,6 +717,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.MediaExif.Camera(childComplexity), true 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": case "MediaEXIF.dateShot":
if e.complexity.MediaExif.DateShot == nil { if e.complexity.MediaExif.DateShot == nil {
break break
@ -1745,7 +1774,7 @@ type Query {
"Get media owned by the logged in user, returned in GeoJson format" "Get media owned by the logged in user, returned in GeoJson format"
myMediaGeoJson: Any! @isAuthorized myMediaGeoJson: Any! @isAuthorized
"Get the mapbox api token, returns null if mapbox is not enabled" "Get the mapbox api token, returns null if mapbox is not enabled"
mapboxToken: String @isAuthorized mapboxToken: String
shareToken(credentials: ShareTokenCredentials!): ShareToken! shareToken(credentials: ShareTokenCredentials!): ShareToken!
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
@ -1955,8 +1984,6 @@ type Album {
path: [Album!]! path: [Album!]!
shares: [ShareToken!]! shares: [ShareToken!]!
#coverID: Int
} }
type MediaURL { type MediaURL {
@ -2029,6 +2056,15 @@ type MediaEXIF {
flash: Int flash: Int
"An index describing the mode for adjusting the exposure of the image" "An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int exposureProgram: Int
"GPS coordinates of where the image was taken"
coordinates: Coordinates
}
type Coordinates {
"GPS latitude in degrees"
latitude: Float!
"GPS longitude in degrees"
longitude: Float!
} }
type VideoMetadata { type VideoMetadata {
@ -3459,6 +3495,76 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr
return ec.marshalOString2ᚖstring(ctx, field.Selections, res) 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) { func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -3789,14 +3895,14 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
Object: "ImageFace", Object: "ImageFace",
Field: field, Field: field,
Args: nil, Args: nil,
IsMethod: false, IsMethod: true,
IsResolver: false, IsResolver: true,
} }
ctx = graphql.WithFieldContext(ctx, fc) ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return obj.Media, nil return ec.resolvers.ImageFace().Media(rctx, obj)
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -3808,9 +3914,9 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
} }
return graphql.Null return graphql.Null
} }
res := resTmp.(models.Media) res := resTmp.(*models.Media)
fc.Result = res fc.Result = res
return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
} }
func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) { func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) {
@ -4853,6 +4959,38 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel
return ec.marshalOInt2ᚖint64(ctx, field.Selections, res) 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) { func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -7283,28 +7421,8 @@ func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphq
ctx = graphql.WithFieldContext(ctx, fc) ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children
ctx = rctx // use context from middleware stack in children return ec.resolvers.Query().MapboxToken(rctx)
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)
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -10429,6 +10547,38 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select
return out 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"} var faceGroupImplementors = []string{"FaceGroup"}
func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler { 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) atomic.AddUint32(&invalids, 1)
} }
case "media": case "media":
out.Values[i] = ec._ImageFace_media(ctx, field, obj) field := field
if out.Values[i] == graphql.Null { out.Concurrently(i, func() (res graphql.Marshaler) {
atomic.AddUint32(&invalids, 1) 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": case "rectangle":
out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj) out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@ -10824,6 +10983,8 @@ func (ec *executionContext) _MediaEXIF(ctx context.Context, sel ast.SelectionSet
out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj) out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj)
case "exposureProgram": case "exposureProgram":
out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj) out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj)
case "coordinates":
out.Values[i] = ec._MediaEXIF_coordinates(ctx, field, obj)
default: default:
panic("unknown field " + strconv.Quote(field.Name)) 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) 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) { func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) {
if v == nil { if v == nil {
return nil, nil return nil, nil

View File

@ -31,6 +31,19 @@ type ImageFace struct {
Rectangle FaceRectangle `gorm:"not null"` Rectangle FaceRectangle `gorm:"not null"`
} }
func (f *ImageFace) FillMedia(db *gorm.DB) error {
if f.Media.ID != 0 {
// media already exists
return nil
}
if err := db.Model(&f).Association("Media").Find(&f.Media); err != nil {
return err
}
return nil
}
type FaceDescriptor face.Descriptor type FaceDescriptor face.Descriptor
// GormDataType datatype used in database // GormDataType datatype used in database

View File

@ -15,6 +15,13 @@ type AuthorizeResult struct {
Token *string `json:"token"` 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 { type MediaDownload struct {
Title string `json:"title"` Title string `json:"title"`
MediaURL *MediaURL `json:"mediaUrl"` MediaURL *MediaURL `json:"mediaUrl"`

View File

@ -28,3 +28,14 @@ func (MediaEXIF) TableName() string {
func (exif *MediaEXIF) Media() *Media { func (exif *MediaEXIF) Media() *Media {
panic("not implemented") 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,
}
}

View File

@ -46,6 +46,14 @@ func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace)
return &faceGroup, nil return &faceGroup, nil
} }
func (r imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
if err := obj.FillMedia(r.Database); err != nil {
return nil, err
}
return &obj.Media, nil
}
func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) { func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
user := auth.UserFromContext(ctx) user := auth.UserFromContext(ctx)
if user == nil { if user == nil {

View File

@ -76,7 +76,7 @@ type Query {
"Get media owned by the logged in user, returned in GeoJson format" "Get media owned by the logged in user, returned in GeoJson format"
myMediaGeoJson: Any! @isAuthorized myMediaGeoJson: Any! @isAuthorized
"Get the mapbox api token, returns null if mapbox is not enabled" "Get the mapbox api token, returns null if mapbox is not enabled"
mapboxToken: String @isAuthorized mapboxToken: String
shareToken(credentials: ShareTokenCredentials!): ShareToken! shareToken(credentials: ShareTokenCredentials!): ShareToken!
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean! shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
@ -358,6 +358,15 @@ type MediaEXIF {
flash: Int flash: Int
"An index describing the mode for adjusting the exposure of the image" "An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int exposureProgram: Int
"GPS coordinates of where the image was taken"
coordinates: Coordinates
}
type Coordinates {
"GPS latitude in degrees"
latitude: Float!
"GPS longitude in degrees"
longitude: Float!
} }
type VideoMetadata { type VideoMetadata {

View File

@ -1,7 +1,20 @@
module.exports = { module.exports = {
skipDefaultValues: locale => locale != 'en', skipDefaultValues: locale => locale != 'en',
sort: true, 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}', input: 'src/**/*.{js,ts,jsx,tsx}',
output: 'src/extractedTranslations/$LOCALE/$NAMESPACE.json', output: 'src/extractedTranslations/$LOCALE/$NAMESPACE.json',
} }

25
ui/package-lock.json generated
View File

@ -12,7 +12,7 @@
"@apollo/client": "^3.3.21", "@apollo/client": "^3.3.21",
"@babel/preset-typescript": "^7.14.5", "@babel/preset-typescript": "^7.14.5",
"@craco/craco": "^6.2.0", "@craco/craco": "^6.2.0",
"@headlessui/react": "^1.3.0", "@headlessui/react": "^1.4.1",
"@react-aria/focus": "^3.4.0", "@react-aria/focus": "^3.4.0",
"@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-babel": "^5.3.0",
"@types/geojson": "^7946.0.8", "@types/geojson": "^7946.0.8",
@ -49,6 +49,7 @@
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"styled-components": "^5.3.0", "styled-components": "^5.3.0",
"subscriptions-transport-ws": "^0.9.19", "subscriptions-transport-ws": "^0.9.19",
"tailwind-override": "^0.2.3",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"url-join": "^4.0.1" "url-join": "^4.0.1"
@ -2102,9 +2103,9 @@
} }
}, },
"node_modules/@headlessui/react": { "node_modules/@headlessui/react": {
"version": "1.3.0", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz",
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==", "integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -24041,6 +24042,11 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1" "url": "https://github.com/chalk/slice-ansi?sponsor=1"
} }
}, },
"node_modules/tailwind-override": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tailwind-override/-/tailwind-override-0.2.3.tgz",
"integrity": "sha512-psWRqXL3TiI2h/YtzRq7dwKO6N7CrsEs4v99rNHgqEclfx4IioM0cHZ9O6pzerV3E6bZi6DhCbeq0z67Xs5PIQ=="
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"name": "@tailwindcss/postcss7-compat", "name": "@tailwindcss/postcss7-compat",
"version": "2.2.4", "version": "2.2.4",
@ -28796,9 +28802,9 @@
} }
}, },
"@headlessui/react": { "@headlessui/react": {
"version": "1.3.0", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz",
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==", "integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==",
"requires": {} "requires": {}
}, },
"@humanwhocodes/config-array": { "@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": { "tailwindcss": {
"version": "npm:@tailwindcss/postcss7-compat@2.2.4", "version": "npm:@tailwindcss/postcss7-compat@2.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz",

View File

@ -12,7 +12,7 @@
"@apollo/client": "^3.3.21", "@apollo/client": "^3.3.21",
"@babel/preset-typescript": "^7.14.5", "@babel/preset-typescript": "^7.14.5",
"@craco/craco": "^6.2.0", "@craco/craco": "^6.2.0",
"@headlessui/react": "^1.3.0", "@headlessui/react": "^1.4.1",
"@react-aria/focus": "^3.4.0", "@react-aria/focus": "^3.4.0",
"@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-babel": "^5.3.0",
"@types/geojson": "^7946.0.8", "@types/geojson": "^7946.0.8",
@ -49,6 +49,7 @@
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"styled-components": "^5.3.0", "styled-components": "^5.3.0",
"subscriptions-transport-ws": "^0.9.19", "subscriptions-transport-ws": "^0.9.19",
"tailwind-override": "^0.2.3",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"url-join": "^4.0.1" "url-join": "^4.0.1"
@ -63,7 +64,7 @@
"lint:types": "tsc --noemit", "lint:types": "tsc --noemit",
"jest": "craco test --setupFilesAfterEnv ./testing/setupTests.ts", "jest": "craco test --setupFilesAfterEnv ./testing/setupTests.ts",
"jest:ci": "CI=true craco test --setupFilesAfterEnv ./testing/setupTests.ts --verbose --ci --coverage", "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", "extractTranslations": "i18next -c i18next-parser.config.js",
"prepare": "(cd .. && npx husky install)" "prepare": "(cd .. && npx husky install)"
}, },
@ -71,12 +72,12 @@
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0", "@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.1.9", "@testing-library/user-event": "^13.1.9",
"apollo": "2.33.4",
"apollo-language-server": "1.26.3",
"husky": "^6.0.0", "husky": "^6.0.0",
"i18next-parser": "^4.2.0", "i18next-parser": "^4.2.0",
"lint-staged": "^11.0.1", "lint-staged": "^11.0.1",
"tsc-files": "^1.1.2", "tsc-files": "^1.1.2"
"apollo": "2.33.4",
"apollo-language-server": "1.26.3"
}, },
"prettier": { "prettier": {
"trailingComma": "es5", "trailingComma": "es5",

View File

@ -2,6 +2,7 @@ import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import PeoplePage, { import PeoplePage, {
FaceDetails, FaceDetails,
FaceGroup,
MY_FACES_QUERY, MY_FACES_QUERY,
SET_GROUP_LABEL_MUTATION, SET_GROUP_LABEL_MUTATION,
} from './PeoplePage' } from './PeoplePage'
@ -144,7 +145,11 @@ describe('FaceDetails component', () => {
render( render(
<MockedProvider mocks={[]} addTypename={false}> <MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={emptyFaceGroup} /> <FaceDetails
editLabel={false}
setEditLabel={jest.fn()}
group={emptyFaceGroup}
/>
</MockedProvider> </MockedProvider>
) )
@ -159,7 +164,11 @@ describe('FaceDetails component', () => {
render( render(
<MockedProvider mocks={[]} addTypename={false}> <MockedProvider mocks={[]} addTypename={false}>
<FaceDetails group={labeledFaceGroup} /> <FaceDetails
editLabel={false}
setEditLabel={jest.fn()}
group={labeledFaceGroup}
/>
</MockedProvider> </MockedProvider>
) )
@ -190,7 +199,9 @@ describe('FaceDetails component', () => {
] ]
render( render(
<MockedProvider mocks={graphqlMocks} addTypename={false}> <MockedProvider mocks={graphqlMocks} addTypename={false}>
<FaceDetails group={faceGroup} /> <MemoryRouter>
<FaceGroup group={faceGroup} />
</MemoryRouter>
</MockedProvider> </MockedProvider>
) )
@ -211,4 +222,30 @@ describe('FaceDetails component', () => {
expect(graphqlMocks[0].newData).toHaveBeenCalled() expect(graphqlMocks[0].newData).toHaveBeenCalled()
}) })
}) })
test('cancel add label to face group', async () => {
render(
<MockedProvider mocks={[]} addTypename={false}>
<MemoryRouter>
<FaceGroup group={faceGroup} />
</MemoryRouter>
</MockedProvider>
)
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()
})
}) })

View File

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

View File

@ -1,4 +1,4 @@
import { gql, useMutation } from '@apollo/client' import { BaseMutationOptions, gql, useMutation } from '@apollo/client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom' 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 = { type DetachImageFacesModalProps = {
open: boolean open: boolean
setOpen(open: boolean): void setOpen(open: boolean): void
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
selectedImageFaces?: (
| myFaces_myFaceGroups_imageFaces
| singleFaceGroup_faceGroup_imageFaces
)[]
} }
const DetachImageFacesModal = ({ const DetachImageFacesModal = ({
open, open,
setOpen, setOpen,
faceGroup, faceGroup,
selectedImageFaces: selectedImageFacesProp,
}: DetachImageFacesModalProps) => { }: DetachImageFacesModalProps) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -46,10 +80,7 @@ const DetachImageFacesModal = ({
>([]) >([])
const history = useHistory() const history = useHistory()
const [detachImageFacesMutation] = useMutation< const detachImageFacesMutation = useDetachImageFaces({
detachImageFaces,
detachImageFacesVariables
>(DETACH_IMAGE_FACES_MUTATION, {
refetchQueries: [ refetchQueries: [
{ {
query: MY_FACES_QUERY, 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(() => { useEffect(() => {
if (!open) { if (!open) {
setSelectedImageFaces([]) setSelectedImageFaces([])
@ -65,20 +109,6 @@ const DetachImageFacesModal = ({
if (open == false) return null 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 ?? [] const imageFaces = faceGroup?.imageFaces ?? []
return ( return (
@ -94,7 +124,7 @@ const DetachImageFacesModal = ({
actions={[ actions={[
{ {
key: 'cancel', key: 'cancel',
label: 'Cancel', label: t('general.action.cancel', 'Cancel'),
onClick: () => setOpen(false), onClick: () => setOpen(false),
}, },
{ {

View File

@ -8,7 +8,7 @@ import React, {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { isNil } from '../../../helpers/utils' import { isNil } from '../../../helpers/utils'
import { Button, TextField } from '../../../primitives/form/Input' 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 { import {
setGroupLabel, setGroupLabel,
setGroupLabelVariables, setGroupLabelVariables,
@ -112,6 +112,11 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
open={mergeModalOpen} open={mergeModalOpen}
setOpen={setMergeModalOpen} setOpen={setMergeModalOpen}
sourceFaceGroup={faceGroup} sourceFaceGroup={faceGroup}
refetchQueries={[
{
query: MY_FACES_QUERY,
},
]}
/> />
<MoveImageFacesModal <MoveImageFacesModal
open={moveModalOpen} open={moveModalOpen}
@ -133,16 +138,24 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
<div className="mb-2">{title}</div> <div className="mb-2">{title}</div>
<ul className="flex gap-2 flex-wrap mb-6"> <ul className="flex gap-2 flex-wrap mb-6">
<li> <li>
<Button onClick={() => setEditLabel(true)}>{t('people_page.action_label.change_label', 'Change label')}</Button> <Button onClick={() => setEditLabel(true)}>
{t('people_page.action_label.change_label', 'Change label')}
</Button>
</li> </li>
<li> <li>
<Button onClick={() => setMergeModalOpen(true)}>{t('people_page.action_label.merge_face', 'Merge face')}</Button> <Button onClick={() => setMergeModalOpen(true)}>
{t('people_page.action_label.merge_people', 'Merge people')}
</Button>
</li> </li>
<li> <li>
<Button onClick={() => setDetachModalOpen(true)}>{t('people_page.action_label.detach_face', 'Detach face')}</Button> <Button onClick={() => setDetachModalOpen(true)}>
{t('people_page.action_label.detach_images', 'Detach images')}
</Button>
</li> </li>
<li> <li>
<Button onClick={() => setMoveModalOpen(true)}>{t('people_page.action_label.move_faces', 'Moves faces')}</Button> <Button onClick={() => setMoveModalOpen(true)}>
{t('people_page.action_label.move_faces', 'Move faces')}
</Button>
</li> </li>
</ul> </ul>
</div> </div>

View File

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

View File

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

View File

@ -52,14 +52,6 @@ const MapClusterMarker = ({
const thumbnail = JSON.parse(marker.thumbnail) const thumbnail = JSON.parse(marker.thumbnail)
const presentMedia = () => { const presentMedia = () => {
// presentMarkerClicked({
// dispatchMedia: dispatchMarkerMedia,
// mediaState: markerMediaState,
// marker: {
// cluster: !!marker.cluster,
// id: marker.cluster ? marker.cluster_id : marker.media_id,
// },
// })
dispatchMarkerMedia({ dispatchMarkerMedia({
type: 'replacePresentMarker', type: 'replacePresentMarker',
marker: { marker: {
@ -67,10 +59,6 @@ const MapClusterMarker = ({
id: marker.cluster ? marker.cluster_id : marker.media_id, id: marker.cluster ? marker.cluster_id : marker.media_id,
}, },
}) })
// setPresentMarker({
// cluster: !!marker.cluster,
// id: marker.cluster ? marker.cluster_id : marker.media_id,
// })
} }
return ( return (

View File

@ -80,6 +80,9 @@ type MapPresetMarkerProps = {
dispatchMarkerMedia: React.Dispatch<PlacesAction> dispatchMarkerMedia: React.Dispatch<PlacesAction>
} }
/**
* Full-screen present-view that works with PlacesState
*/
const MapPresentMarker = ({ const MapPresentMarker = ({
map, map,
markerMediaState, markerMediaState,

View File

@ -1,30 +1,24 @@
import { gql, useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import type mapboxgl from 'mapbox-gl' 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 { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import Layout from '../../components/layout/Layout' import Layout from '../../components/layout/Layout'
import { makeUpdateMarkers } from './mapboxHelperFunctions' import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions'
import MapPresentMarker from './MapPresentMarker' import useMapboxMap from '../../components/mapbox/MapboxMap'
import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer' import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer'
import { placesReducer } from './placesReducer' import MapPresentMarker from './MapPresentMarker'
import { PlacesAction, placesReducer } from './placesReducer'
import 'mapbox-gl/dist/mapbox-gl.css' import { mediaGeoJson } from './__generated__/mediaGeoJson'
const MapWrapper = styled.div` const MapWrapper = styled.div`
width: 100%; width: 100%;
height: calc(100vh - 120px); height: calc(100vh - 120px);
` `
const MapContainer = styled.div`
width: 100%;
height: 100%;
`
const MAPBOX_DATA_QUERY = gql` const MAPBOX_DATA_QUERY = gql`
query placePageMapboxToken { query mediaGeoJson {
mapboxToken
myMediaGeoJson myMediaGeoJson
} }
` `
@ -37,10 +31,9 @@ export type PresentMarker = {
const MapPage = () => { const MapPage = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl | null>() const { data: mapboxData } = useQuery<mediaGeoJson>(MAPBOX_DATA_QUERY, {
const mapContainer = useRef<HTMLDivElement | null>(null) fetchPolicy: 'cache-first',
const map = useRef<mapboxgl.Map | null>(null) })
// const [presentMarker, setPresentMarker] = useState<PresentMarker | null>(null)
const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, { const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, {
presenting: false, presenting: false,
@ -48,76 +41,21 @@ const MapPage = () => {
media: [], media: [],
}) })
const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY) const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({
configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }),
})
useEffect(() => { urlPresentModeSetupHook({
async function loadMapboxLibrary() { dispatchMedia: dispatchMarkerMedia,
const mapbox = (await import('mapbox-gl')).default openPresentMode: event => {
dispatchMarkerMedia({
setMapboxLibrary(mapbox) type: 'openPresentMode',
} activeIndex: event.state.activeIndex,
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],
},
}) })
},
})
// Add dummy layer for features to be queryable if (mapboxData && mapboxToken == null) {
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) {
return ( return (
<Layout title={t('places_page.title', 'Places')}> <Layout title={t('places_page.title', 'Places')}>
<h1>Mapbox token is not set</h1> <h1>Mapbox token is not set</h1>
@ -134,34 +72,64 @@ const MapPage = () => {
) )
} }
urlPresentModeSetupHook({
dispatchMedia: dispatchMarkerMedia,
openPresentMode: event => {
dispatchMarkerMedia({
type: 'openPresentMode',
activeIndex: event.state.activeIndex,
})
},
})
return ( return (
<Layout title="Places"> <Layout title="Places">
<Helmet> <Helmet>
{/* <link rel="stylesheet" href="/mapbox-gl.css" /> */} {/* <link rel="stylesheet" href="/mapbox-gl.css" /> */}
{/* <style type="text/css">{mapboxStyles}</style> */} {/* <style type="text/css">{mapboxStyles}</style> */}
</Helmet> </Helmet>
<MapWrapper> <MapWrapper>{mapContainer}</MapWrapper>
<MapContainer ref={mapContainer}></MapContainer>
</MapWrapper>
<MapPresentMarker <MapPresentMarker
map={map.current} map={mapboxMap}
markerMediaState={markerMediaState} markerMediaState={markerMediaState}
dispatchMarkerMedia={dispatchMarkerMedia} dispatchMarkerMedia={dispatchMarkerMedia}
// presentMarker={presentMarker}
// setPresentMarker={setPresentMarker}
/> />
</Layout> </Layout>
) )
} }
const configureMapbox =
({
mapboxData,
dispatchMarkerMedia,
}: {
mapboxData?: mediaGeoJson
dispatchMarkerMedia: React.Dispatch<PlacesAction>
}) =>
(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 export default MapPage

View File

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

View File

@ -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<PlacesAction>
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
}
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<PlacesAction>
}
) {
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
const el = document.createElement('div')
ReactDOM.render(
<MapClusterMarker
marker={geojsonProps}
dispatchMarkerMedia={dispatchMarkerMedia}
/>,
el
)
return el
}

View File

@ -66,6 +66,7 @@ export const SHARE_ALBUM_QUERY = gql`
url url
} }
exif { exif {
id
camera camera
maker maker
lens lens
@ -76,6 +77,10 @@ export const SHARE_ALBUM_QUERY = gql`
focalLength focalLength
flash flash
exposureProgram exposureProgram
coordinates {
latitude
longitude
}
} }
} }
} }

View File

@ -6,7 +6,7 @@ import {
ProtectedVideo, ProtectedVideo,
} from '../../components/photoGallery/ProtectedMedia' } from '../../components/photoGallery/ProtectedMedia'
import { SidebarContext } from '../../components/sidebar/Sidebar' 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 { useTranslation } from 'react-i18next'
import { SharePageToken_shareToken_media } from './__generated__/SharePageToken' import { SharePageToken_shareToken_media } from './__generated__/SharePageToken'
import { MediaType } from '../../__generated__/globalTypes' import { MediaType } from '../../__generated__/globalTypes'

View File

@ -59,6 +59,10 @@ export const SHARE_TOKEN_QUERY = gql`
focalLength focalLength
flash flash
exposureProgram exposureProgram
coordinates {
longitude
latitude
}
} }
} }
} }

View File

@ -3,172 +3,188 @@
// @generated // @generated
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { MediaType } from "./../../../__generated__/globalTypes"; import { MediaType } from './../../../__generated__/globalTypes'
// ==================================================== // ====================================================
// GraphQL query operation: SharePageToken // GraphQL query operation: SharePageToken
// ==================================================== // ====================================================
export interface SharePageToken_shareToken_album { export interface SharePageToken_shareToken_album {
__typename: "Album"; __typename: 'Album'
id: string; id: string
} }
export interface SharePageToken_shareToken_media_thumbnail { export interface SharePageToken_shareToken_media_thumbnail {
__typename: "MediaURL"; __typename: 'MediaURL'
/** /**
* URL for previewing the image * URL for previewing the image
*/ */
url: string; url: string
/** /**
* Width of the image in pixels * Width of the image in pixels
*/ */
width: number; width: number
/** /**
* Height of the image in pixels * Height of the image in pixels
*/ */
height: number; height: number
} }
export interface SharePageToken_shareToken_media_downloads_mediaUrl { export interface SharePageToken_shareToken_media_downloads_mediaUrl {
__typename: "MediaURL"; __typename: 'MediaURL'
/** /**
* URL for previewing the image * URL for previewing the image
*/ */
url: string; url: string
/** /**
* Width of the image in pixels * Width of the image in pixels
*/ */
width: number; width: number
/** /**
* Height of the image in pixels * Height of the image in pixels
*/ */
height: number; height: number
/** /**
* The file size of the resource in bytes * The file size of the resource in bytes
*/ */
fileSize: number; fileSize: number
} }
export interface SharePageToken_shareToken_media_downloads { export interface SharePageToken_shareToken_media_downloads {
__typename: "MediaDownload"; __typename: 'MediaDownload'
title: string; title: string
mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl; mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl
} }
export interface SharePageToken_shareToken_media_highRes { export interface SharePageToken_shareToken_media_highRes {
__typename: "MediaURL"; __typename: 'MediaURL'
/** /**
* URL for previewing the image * URL for previewing the image
*/ */
url: string; url: string
/** /**
* Width of the image in pixels * Width of the image in pixels
*/ */
width: number; width: number
/** /**
* Height of the image in pixels * Height of the image in pixels
*/ */
height: number; height: number
} }
export interface SharePageToken_shareToken_media_videoWeb { export interface SharePageToken_shareToken_media_videoWeb {
__typename: "MediaURL"; __typename: 'MediaURL'
/** /**
* URL for previewing the image * URL for previewing the image
*/ */
url: string; url: string
/** /**
* Width of the image in pixels * Width of the image in pixels
*/ */
width: number; width: number
/** /**
* Height of the image in pixels * 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 { export interface SharePageToken_shareToken_media_exif {
__typename: "MediaEXIF"; __typename: 'MediaEXIF'
id: string; id: string
/** /**
* The model name of the camera * The model name of the camera
*/ */
camera: string | null; camera: string | null
/** /**
* The maker of the camera * The maker of the camera
*/ */
maker: string | null; maker: string | null
/** /**
* The name of the lens * The name of the lens
*/ */
lens: string | null; lens: string | null
dateShot: any | null; dateShot: any | null
/** /**
* The exposure time of the image * The exposure time of the image
*/ */
exposure: number | null; exposure: number | null
/** /**
* The aperature stops of the image * The aperature stops of the image
*/ */
aperture: number | null; aperture: number | null
/** /**
* The ISO setting of the image * The ISO setting of the image
*/ */
iso: number | null; iso: number | null
/** /**
* The focal length of the lens, when the image was taken * 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 * 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 * 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 { export interface SharePageToken_shareToken_media {
__typename: "Media"; __typename: 'Media'
id: string; id: string
title: string; title: string
type: MediaType; type: MediaType
/** /**
* URL to display the media in a smaller resolution * URL to display the media in a smaller resolution
*/ */
thumbnail: SharePageToken_shareToken_media_thumbnail | null; thumbnail: SharePageToken_shareToken_media_thumbnail | null
downloads: SharePageToken_shareToken_media_downloads[]; downloads: SharePageToken_shareToken_media_downloads[]
/** /**
* URL to display the photo in full resolution, will be null for videos * 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 * 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; videoWeb: SharePageToken_shareToken_media_videoWeb | null
exif: SharePageToken_shareToken_media_exif | null; exif: SharePageToken_shareToken_media_exif | null
} }
export interface SharePageToken_shareToken { export interface SharePageToken_shareToken {
__typename: "ShareToken"; __typename: 'ShareToken'
token: string; token: string
/** /**
* The album this token shares * The album this token shares
*/ */
album: SharePageToken_shareToken_album | null; album: SharePageToken_shareToken_album | null
/** /**
* The media this token shares * The media this token shares
*/ */
media: SharePageToken_shareToken_media | null; media: SharePageToken_shareToken_media | null
} }
export interface SharePageToken { export interface SharePageToken {
shareToken: SharePageToken_shareToken; shareToken: SharePageToken_shareToken
} }
export interface SharePageTokenVariables { export interface SharePageTokenVariables {
token: string; token: string
password?: string | null; password?: string | null
} }

View File

@ -8,15 +8,15 @@
// ==================================================== // ====================================================
export interface albumPathQuery_album_path { export interface albumPathQuery_album_path {
__typename: "Album"; __typename: 'Album'
id: string; id: string
title: string; title: string
} }
export interface albumPathQuery_album { export interface albumPathQuery_album {
__typename: "Album"; __typename: 'Album'
id: string; id: string
path: albumPathQuery_album_path[]; path: albumPathQuery_album_path[]
} }
export interface albumPathQuery { export interface albumPathQuery {
@ -24,9 +24,9 @@ export interface albumPathQuery {
* Get album by id, user must own the album or be admin * 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 * If valid tokenCredentials are provided, the album may be retrived without further authentication
*/ */
album: albumPathQuery_album; album: albumPathQuery_album
} }
export interface albumPathQueryVariables { export interface albumPathQueryVariables {
id: string; id: string
} }

View File

@ -10,8 +10,10 @@ import useDelay from '../../hooks/useDelay'
import { ReactComponent as GearIcon } from './icons/gear.svg' import { ReactComponent as GearIcon } from './icons/gear.svg'
const BreadcrumbList = styled.ol` export const BreadcrumbList = styled.ol<{ hideLastArrow?: boolean }>`
& li::after { &
${({ hideLastArrow }) =>
hideLastArrow ? 'li:not(:last-child)::after' : 'li::after'} {
content: ''; content: '';
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='5px' height='6px' viewBox='0 0 5 6'%3E%3Cpolyline fill='none' stroke='%23979797' points='0.74 0.167710644 3.57228936 3 0.74 5.83228936' /%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='5px' height='6px' viewBox='0 0 5 6'%3E%3Cpolyline fill='none' stroke='%23979797' points='0.74 0.167710644 3.57228936 3 0.74 5.83228936' /%3E%3C/svg%3E");
width: 5px; width: 5px;

View File

@ -2,8 +2,8 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { MediaType } from '../../__generated__/globalTypes' import { MediaType } from '../../__generated__/globalTypes'
import { MediaSidebarMedia } from '../sidebar/MediaSidebar' import { MediaSidebarMedia } from '../sidebar/MediaSidebar/MediaSidebar'
import { sidebarPhoto_media_faces } from '../sidebar/__generated__/sidebarPhoto' import { sidebarMediaQuery_media_faces } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery'
interface FaceBoxStyleProps { interface FaceBoxStyleProps {
$minY: number $minY: number
@ -23,7 +23,7 @@ const FaceBoxStyle = styled(Link)`
` `
type FaceBoxProps = { type FaceBoxProps = {
face: sidebarPhoto_media_faces face: sidebarMediaQuery_media_faces
} }
const FaceBox = ({ face /*media*/ }: FaceBoxProps) => { const FaceBox = ({ face /*media*/ }: FaceBoxProps) => {

View File

@ -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<mapboxgl.MapboxOptions>
}
const useMapboxMap = ({
configureMapbox,
mapboxOptions = undefined,
}: MapboxMapProps) => {
const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl>()
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<mapboxgl.Map | null>(null)
const { data: mapboxData } = useQuery<mapboxToken>(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: <MapContainer ref={mapContainer}></MapContainer>,
mapboxMap: map.current,
mapboxLibrary,
mapboxToken: mapboxData?.mapboxToken || null,
}
}
export default useMapboxMap

View File

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

View File

@ -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<PlacesAction>
}
/**
* 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<PlacesAction>
}
) {
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
const el = document.createElement('div')
ReactDOM.render(
<MapClusterMarker
marker={geojsonProps}
dispatchMarkerMedia={dispatchMarkerMedia}
/>,
el
)
return el
}

View File

@ -3,7 +3,6 @@ import styled from 'styled-components'
import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail' import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail'
import PresentView from './presentView/PresentView' import PresentView from './presentView/PresentView'
import { PresentMediaProps_Media } from './presentView/PresentMedia' import { PresentMediaProps_Media } from './presentView/PresentMedia'
import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto'
import { import {
openPresentModeAction, openPresentModeAction,
PhotoGalleryAction, PhotoGalleryAction,
@ -13,8 +12,9 @@ import {
toggleFavoriteAction, toggleFavoriteAction,
useMarkFavoriteMutation, useMarkFavoriteMutation,
} from './photoGalleryMutations' } from './photoGalleryMutations'
import MediaSidebar from '../sidebar/MediaSidebar' import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar'
import { SidebarContext } from '../sidebar/Sidebar' import { SidebarContext } from '../sidebar/Sidebar'
import { sidebarMediaQuery_media_thumbnail } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery'
const Gallery = styled.div` const Gallery = styled.div`
display: flex; display: flex;
@ -36,7 +36,7 @@ export const PhotoFiller = styled.div`
` `
export interface PhotoGalleryProps_Media extends PresentMediaProps_Media { export interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
thumbnail: sidebarPhoto_media_thumbnail | null thumbnail: sidebarMediaQuery_media_thumbnail | null
favorite?: boolean favorite?: boolean
} }

View File

@ -7,11 +7,15 @@ import * as authentication from '../../helpers/authentication'
jest.mock('../../helpers/authentication.ts') jest.mock('../../helpers/authentication.ts')
const authToken = authentication.authToken as jest.Mock<
ReturnType<typeof authentication.authToken>
>
describe('AuthorizedRoute component', () => { describe('AuthorizedRoute component', () => {
const AuthorizedComponent = () => <div>authorized content</div> const AuthorizedComponent = () => <div>authorized content</div>
test('not logged in', async () => { test('not logged in', async () => {
authentication.authToken.mockImplementation(() => null) authToken.mockImplementation(() => null)
render( render(
<MemoryRouter initialEntries={['/']}> <MemoryRouter initialEntries={['/']}>
@ -24,7 +28,7 @@ describe('AuthorizedRoute component', () => {
}) })
test('logged in', async () => { test('logged in', async () => {
authentication.authToken.mockImplementation(() => 'token-here') authToken.mockImplementation(() => 'token-here')
render( render(
<MemoryRouter initialEntries={['/']}> <MemoryRouter initialEntries={['/']}>

View File

@ -12,6 +12,7 @@ import {
resetAlbumCover, resetAlbumCover,
resetAlbumCoverVariables, resetAlbumCoverVariables,
} from './__generated__/resetAlbumCover' } from './__generated__/resetAlbumCover'
import { authToken } from '../../helpers/authentication'
const RESET_ALBUM_COVER_MUTATION = gql` const RESET_ALBUM_COVER_MUTATION = gql`
mutation resetAlbumCover($albumID: ID!) { mutation resetAlbumCover($albumID: ID!) {
@ -62,6 +63,11 @@ export const SidebarPhotoCover = ({ cover_id }: SidebarPhotoCoverProps) => {
setButtonDisabled(false) setButtonDisabled(false)
}, [cover_id]) }, [cover_id])
// hide when not authenticated
if (!authToken()) {
return null
}
return ( return (
<SidebarSection> <SidebarSection>
<SidebarSectionTitle> <SidebarSectionTitle>

View File

@ -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<typeof authentication.authToken>
>
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(
<MockedProvider mocks={[]} addTypename={false}>
<MemoryRouter>
<MediaSidebar media={media} />
</MemoryRouter>
</MockedProvider>
)
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(
<MockedProvider mocks={[]} addTypename={false}>
<MemoryRouter>
<MediaSidebar media={media} />
</MemoryRouter>
</MockedProvider>
)
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()
})
})

View File

@ -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 <PreviewImage src={previewImage?.url} />
}
if (media.type === MediaType.Video) {
return <PreviewVideo media={media} />
}
return <div>ERROR: Unknown media type: {media.type}</div>
}
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 = <MediaSidebarMap coordinates={mediaCoordinates} />
}
let albumPath = null
const mediaAlbum = media.album
if (!isNil(mediaAlbum)) {
const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => (
<li key={album.id} className="inline-block hover:underline">
<Link
className="text-blue-900 hover:underline"
to={`/album/${album.id}`}
>
{album.title}
</Link>
</li>
))
albumPath = (
<div className="mx-4 my-4">
<h2 className="uppercase text-xs text-gray-900 font-semibold">
{t('sidebar.media.album_path', 'Album path')}
</h2>
<BreadcrumbList hideLastArrow={true}>{pathElms}</BreadcrumbList>
</div>
)
}
return (
<div>
<SidebarHeader title={media.title ?? 'Loading...'} />
<div className="lg:mx-4">
{!hidePreview && (
<div
className="w-full h-0 relative"
style={{ paddingTop: `${Math.min(imageAspect, 0.75) * 100}%` }}
>
<PreviewMedia
previewImage={previewImage || undefined}
media={media}
/>
<SidebarFacesOverlay media={media} />
</div>
)}
</div>
<ExifDetails media={media} />
{albumPath}
<MediaSidebarPeople media={media} />
{sidebarMap}
<SidebarMediaDownload media={media} />
<SidebarPhotoShare id={media.id} />
<div className="mt-8">
<SidebarPhotoCover cover_id={media.id} />
</div>
</div>
)
}
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 <SidebarContent media={media} hidePreview={hidePreview} />
}
if (error) return <div>{error.message}</div>
if (loading || data == null) {
return <SidebarContent media={media} hidePreview={hidePreview} />
}
return <SidebarContent media={data.media} hidePreview={hidePreview} />
}
export default MediaSidebar

View File

@ -1,13 +1,15 @@
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/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 () => { test('without EXIF information', async () => {
const media = { const media: MediaSidebarMedia = {
id: '1730', id: '1730',
title: 'media_name.jpg', title: 'media_name.jpg',
type: 'Photo', type: MediaType.Photo,
exif: { exif: {
id: '0', id: '0',
camera: null, camera: null,
@ -20,12 +22,13 @@ describe('MetadataInfo', () => {
focalLength: null, focalLength: null,
flash: null, flash: null,
exposureProgram: null, exposureProgram: null,
coordinates: null,
__typename: 'MediaEXIF', __typename: 'MediaEXIF',
}, },
__typename: 'Media', __typename: 'Media',
} }
render(<MetadataInfo media={media} />) render(<ExifDetails media={media} />)
expect(screen.queryByText('Camera')).not.toBeInTheDocument() expect(screen.queryByText('Camera')).not.toBeInTheDocument()
expect(screen.queryByText('Maker')).not.toBeInTheDocument() expect(screen.queryByText('Maker')).not.toBeInTheDocument()
@ -37,13 +40,14 @@ describe('MetadataInfo', () => {
expect(screen.queryByText('ISO')).not.toBeInTheDocument() expect(screen.queryByText('ISO')).not.toBeInTheDocument()
expect(screen.queryByText('Focal length')).not.toBeInTheDocument() expect(screen.queryByText('Focal length')).not.toBeInTheDocument()
expect(screen.queryByText('Flash')).not.toBeInTheDocument() expect(screen.queryByText('Flash')).not.toBeInTheDocument()
expect(screen.queryByText('Coordinates')).not.toBeInTheDocument()
}) })
test('with EXIF information', async () => { test('with EXIF information', async () => {
const media = { const media: MediaSidebarMedia = {
id: '1730', id: '1730',
title: 'media_name.jpg', title: 'media_name.jpg',
type: 'Photo', type: MediaType.Photo,
exif: { exif: {
id: '1666', id: '1666',
camera: 'Canon EOS R', camera: 'Canon EOS R',
@ -56,12 +60,17 @@ describe('MetadataInfo', () => {
focalLength: 24, focalLength: 24,
flash: 9, flash: 9,
exposureProgram: 3, exposureProgram: 3,
coordinates: {
__typename: 'Coordinates',
latitude: 41.40338,
longitude: 2.17403,
},
__typename: 'MediaEXIF', __typename: 'MediaEXIF',
}, },
__typename: 'Media', __typename: 'Media',
} }
render(<MetadataInfo media={media} />) render(<ExifDetails media={media} />)
expect(screen.getByText('Camera')).toBeInTheDocument() expect(screen.getByText('Camera')).toBeInTheDocument()
expect(screen.getByText('Canon EOS R')).toBeInTheDocument() expect(screen.getByText('Canon EOS R')).toBeInTheDocument()
@ -94,5 +103,8 @@ describe('MetadataInfo', () => {
expect(screen.getByText('Flash')).toBeInTheDocument() expect(screen.getByText('Flash')).toBeInTheDocument()
expect(screen.getByText('On, Fired')).toBeInTheDocument() expect(screen.getByText('On, Fired')).toBeInTheDocument()
expect(screen.getByText('Coordinates')).toBeInTheDocument()
expect(screen.getByText('41.40338, 2.17403')).toBeInTheDocument()
}) })
}) })

View File

@ -1,143 +1,20 @@
import React, { useEffect } from 'react' import React 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 { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MediaType } from '../../__generated__/globalTypes' import styled from 'styled-components'
import { TranslationFn } from '../../localization' import { isNil } from '../../../helpers/utils'
import { import { TranslationFn } from '../../../localization'
sidebarPhoto, import SidebarItem from '../SidebarItem'
sidebarPhotoVariables, import { MediaSidebarMedia } from './MediaSidebar'
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 <PreviewImage src={previewImage?.url} />
}
if (media.type === MediaType.Video) {
return <PreviewVideo media={media} />
}
return <div>ERROR: Unknown media type: {media.type}</div>
}
const MetadataInfoContainer = styled.div` const MetadataInfoContainer = styled.div`
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
` `
type MediaInfoProps = { type ExifDetailsProps = {
media?: MediaSidebarMedia media?: MediaSidebarMedia
} }
export const MetadataInfo = ({ media }: MediaInfoProps) => { const ExifDetails = ({ media }: ExifDetailsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
let exifItems: JSX.Element[] = [] let exifItems: JSX.Element[] = []
@ -170,6 +47,13 @@ export const MetadataInfo = ({ media }: MediaInfoProps) => {
exif.exposure = `1/${Math.round(1 / exif.exposure)}` 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) const exposurePrograms = exposureProgramsLookup(t)
if ( if (
@ -246,6 +130,7 @@ const exifNameLookup = (t: TranslationFn): { [key: string]: string } => ({
iso: t('sidebar.media.exif.name.iso', 'ISO'), iso: t('sidebar.media.exif.name.iso', 'ISO'),
focalLength: t('sidebar.media.exif.name.focal_length', 'Focal length'), focalLength: t('sidebar.media.exif.name.focal_length', 'Focal length'),
flash: t('sidebar.media.exif.name.flash', 'Flash'), flash: t('sidebar.media.exif.name.flash', 'Flash'),
coordinates: t('sidebar.media.exif.name.coordinates', 'Coordinates'),
}) })
// From https://exiftool.org/TagNames/EXIF.html // From https://exiftool.org/TagNames/EXIF.html
@ -331,106 +216,4 @@ const flashLookup = (t: TranslationFn): { [key: number]: string } => {
} }
} }
type SidebarContentProps = { export default ExifDetails
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 (
<div>
<SidebarHeader title={media.title ?? 'Loading...'} />
<div className="lg:mx-4">
{!hidePreview && (
<div
className="w-full h-0 relative"
style={{ paddingTop: `${Math.min(imageAspect, 0.75) * 100}%` }}
>
<PreviewMedia
previewImage={previewImage || undefined}
media={media}
/>
<SidebarFacesOverlay media={media} />
</div>
)}
</div>
<MetadataInfo media={media} />
<SidebarMediaDownload media={media} />
<SidebarPhotoShare id={media.id} />
<div className="mt-8">
<SidebarPhotoCover cover_id={media.id} />
</div>
</div>
)
}
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 <SidebarContent media={media} hidePreview={hidePreview} />
}
if (error) return <div>{error.message}</div>
if (loading || data == null) {
return <SidebarContent media={media} hidePreview={hidePreview} />
}
return <SidebarContent media={data.media} hidePreview={hidePreview} />
}
export default MediaSidebar

View File

@ -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 (
<SidebarSection>
<SidebarSectionTitle>
{t('sidebar.location.title', 'Location')}
</SidebarSectionTitle>
<div className="w-full h-64">{mapContainer}</div>
</SidebarSection>
)
}
export default MediaSidebarMap

View File

@ -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 (
<Menu.Item>
{({ active }) => (
<button
onClick={onClick}
className={tailwindClassNames(
`whitespace-normal w-full block py-1 cursor-pointer ${
active ? 'bg-gray-50 text-black' : 'text-gray-700'
}`,
className
)}
>
{label}
</button>
)}
</Menu.Item>
)
}
type PersonMoreMenuProps = {
face: sidebarMediaQuery_media_faces
setChangeLabel: React.Dispatch<React.SetStateAction<boolean>>
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 = (
<>
<MergeFaceGroupsModal
sourceFaceGroup={face.faceGroup}
open={mergeModalOpen}
setOpen={setMergeModalOpen}
refetchQueries={refetchQueries}
/>
<MoveImageFacesModal
faceGroup={{ imageFaces: [], ...face.faceGroup }}
open={moveModalOpen}
setOpen={setMoveModalOpen}
preselectedImageFaces={[face]}
/>
</>
)
const detachImageFace = () => {
if (
!confirm(
t(
'sidebar.people.confirm_image_detach',
'Are you sure you want to detach this image?'
)
)
)
return
detachImageFaceMutation([face]).then(({ data }) => {
if (isNil(data)) throw new Error('Expected data not to be null')
history.push(`/people/${data.detachImageFaces.id}`)
})
}
return (
<>
<Menu
as="div"
className={tailwindClassNames('relative inline-block', className)}
>
<Menu.Button as={Button} className="px-1.5 py-1.5 align-middle ml-1">
<PeopleDotsIcon className="text-gray-500" />
</Menu.Button>
<Menu.Items className="">
<ArrowPopoverPanel width={120} flipped={menuFlipped}>
<PersonMoreMenuItem
onClick={() => setChangeLabel(true)}
className="border-b"
label={t('people_page.action_label.change_label', 'Change label')}
/>
<PersonMoreMenuItem
onClick={() => setMergeModalOpen(true)}
className="border-b"
label={t('sidebar.people.action_label.merge_face', 'Merge face')}
/>
<PersonMoreMenuItem
onClick={() => detachImageFace()}
className="border-b"
label={t(
'sidebar.people.action_label.detach_image',
'Detach image'
)}
/>
<PersonMoreMenuItem
onClick={() => setMoveModalOpen(true)}
label={t('sidebar.people.action_label.move_face', 'Move face')}
/>
</ArrowPopoverPanel>
</Menu.Items>
</Menu>
{modals}
</>
)
}
type MediaSidebarFaceProps = {
face: sidebarMediaQuery_media_faces
menuFlipped: boolean
}
const MediaSidebarPerson = ({ face, menuFlipped }: MediaSidebarFaceProps) => {
const [changeLabel, setChangeLabel] = useState(false)
return (
<li className="inline-block">
<Link to={`/people/${face.faceGroup.id}`}>
<FaceCircleImage imageFace={face} selectable={true} size="92px" />
</Link>
<div className="mt-1 whitespace-nowrap">
<FaceDetails
className="text-sm max-w-[80px] align-middle"
textFieldClassName="w-[100px]"
group={face.faceGroup}
editLabel={changeLabel}
setEditLabel={setChangeLabel}
/>
{!changeLabel && (
<PersonMoreMenu
menuFlipped={menuFlipped}
className="pl-0.5"
face={face}
setChangeLabel={setChangeLabel}
/>
)}
</div>
</li>
)
}
type MediaSidebarFacesProps = {
media: MediaSidebarMedia
}
const MediaSidebarPeople = ({ media }: MediaSidebarFacesProps) => {
const { t } = useTranslation()
const faceElms = (media.faces ?? []).map((face, i) => (
<MediaSidebarPerson key={face.id} face={face} menuFlipped={i == 0} />
))
if (faceElms.length == 0) return null
return (
<SidebarSection>
<SidebarSectionTitle>
{t('sidebar.people.title', 'People')}
</SidebarSectionTitle>
<div
className="overflow-x-auto mb-[-200px]"
style={{ scrollbarWidth: 'none' }}
>
<ul className="flex gap-4 mx-4">{faceElms}</ul>
<div className="h-[200px]"></div>
</div>
</SidebarSection>
)
}
export default MediaSidebarPeople

View File

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

View File

@ -0,0 +1,3 @@
<svg width="12px" height="3px" viewBox="0 0 8 2" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M1,0 C1.55228475,0 2,0.44771525 2,1 C2,1.55228475 1.55228475,2 1,2 C0.44771525,2 0,1.55228475 0,1 C0,0.44771525 0.44771525,0 1,0 Z M4,0 C4.55228475,0 5,0.44771525 5,1 C5,1.55228475 4.55228475,2 4,2 C3.44771525,2 3,1.55228475 3,1 C3,0.44771525 3.44771525,0 4,0 Z M7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 Z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 583 B

View File

@ -13,7 +13,6 @@ import {
sidebareDeleteShare, sidebareDeleteShare,
sidebareDeleteShareVariables, sidebareDeleteShareVariables,
} from './__generated__/sidebareDeleteShare' } from './__generated__/sidebareDeleteShare'
import { sidbarGetPhotoShares_media_shares } from './__generated__/sidbarGetPhotoShares'
import { import {
sidebarPhotoAddShare, sidebarPhotoAddShare,
sidebarPhotoAddShareVariables, sidebarPhotoAddShareVariables,
@ -25,6 +24,7 @@ import {
import { import {
sidebarGetPhotoShares, sidebarGetPhotoShares,
sidebarGetPhotoSharesVariables, sidebarGetPhotoSharesVariables,
sidebarGetPhotoShares_media_shares,
} from './__generated__/sidebarGetPhotoShares' } from './__generated__/sidebarGetPhotoShares'
import { import {
sidebarGetAlbumShares, sidebarGetAlbumShares,
@ -106,18 +106,38 @@ const DELETE_SHARE_MUTATION = gql`
} }
` `
const ArrowPopoverPanel = styled.div.attrs({ export const ArrowPopoverPanel = styled.div.attrs({
className: 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 { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 18px; top: 18px;
right: -7px;
width: 8px; width: 8px;
height: 14px; 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"); 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) => {
</Popover.Button> </Popover.Button>
<Popover.Panel> <Popover.Panel>
<ArrowPopoverPanel> <ArrowPopoverPanel width={260}>
<MorePopoverSectionPassword id={id} share={share} query={query} /> <MorePopoverSectionPassword id={id} share={share} query={query} />
<div className="px-4 py-2 border-t border-gray-200 mt-2 mb-2"> <div className="px-4 py-2 border-t border-gray-200 mt-2 mb-2">
<Checkbox label="Expiration date" /> <Checkbox label="Expiration date" />
@ -358,7 +378,7 @@ type SidebarShareProps = {
id: string id: string
isPhoto: boolean isPhoto: boolean
loading: boolean loading: boolean
shares?: sidbarGetPhotoShares_media_shares[] shares?: sidebarGetPhotoShares_media_shares[]
shareItem(item: { variables: { id: string } }): void shareItem(item: { variables: { id: string } }): void
} }

View File

@ -4,7 +4,7 @@ import { useLazyQuery, gql } from '@apollo/client'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TranslationFn } from '../../localization' import { TranslationFn } from '../../localization'
import { MediaSidebarMedia } from './MediaSidebar' import { MediaSidebarMedia } from './MediaSidebar/MediaSidebar'
import { import {
sidebarDownloadQuery, sidebarDownloadQuery,
sidebarDownloadQueryVariables, sidebarDownloadQueryVariables,

View File

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

View File

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

View File

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

View File

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

View File

@ -53,7 +53,9 @@ const DateSelector = ({ filterDate, setFilterDate }: DateSelectorProps) => {
const yearItems = years.map<DropdownItem>(x => ({ const yearItems = years.map<DropdownItem>(x => ({
value: `${x}`, value: `${x}`,
label: `${x} and earlier`, label: t('timeline_filter.date.dropdown_year', '{{year}} and earlier', {
year: x,
}),
})) }))
items = [...items, ...yearItems] items = [...items, ...yearItems]
} }

View File

@ -6,7 +6,7 @@ import {
toggleFavoriteAction, toggleFavoriteAction,
useMarkFavoriteMutation, useMarkFavoriteMutation,
} from '../photoGallery/photoGalleryMutations' } from '../photoGallery/photoGalleryMutations'
import MediaSidebar from '../sidebar/MediaSidebar' import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar'
import { SidebarContext } from '../sidebar/Sidebar' import { SidebarContext } from '../sidebar/Sidebar'
import { import {
getActiveTimelineImage, getActiveTimelineImage,

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "Ændre navn", "change_label": "Ændre navn",
"detach_face": "Løsriv billeder", "detach_images": "Løsriv billeder",
"merge_face": "Sammenflet personer", "merge_people": "Sammenflet personer",
"move_faces": "Flyt ansigter" "move_faces": "Flyt ansigter"
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Download" "title": "Download"
}, },
"location": {
"title": "Lokation"
},
"media": { "media": {
"album_path": "Album sti",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Actionprogram", "action_program": "Actionprogram",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Blænde", "aperture": "Blænde",
"camera": "Kamera", "camera": "Kamera",
"coordinates": "Koordinater",
"date_shot": "Dato", "date_shot": "Dato",
"exposure": "Lukketid", "exposure": "Lukketid",
"exposure_program": "Lukketid program", "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": { "sharing": {
"add_share": "Tilføj deling", "add_share": "Tilføj deling",
"copy_link": "Kopier link", "copy_link": "Kopier link",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "Fra i dag", "dropdown_all": "Fra i dag",
"dropdown_year": "{{year}} og tidligere",
"label": "Dato" "label": "Dato"
} }
}, },

View File

@ -42,6 +42,15 @@
"merge_face": null, "merge_face": null,
"detach_face": null, "detach_face": null,
"move_faces": 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": { "settings": {
@ -71,6 +80,9 @@
"mega_byte_plural": null, "mega_byte_plural": null,
"tera_byte_plural": null "tera_byte_plural": null
} }
},
"media": {
"album": "Album"
} }
}, },
"timeline_filter": { "timeline_filter": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Download" "title": "Download"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Action (kurze Verschlusszeit)", "action_program": "Action (kurze Verschlusszeit)",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Blende", "aperture": "Blende",
"camera": "Kamera", "camera": "Kamera",
"coordinates": "",
"date_shot": "Aufnahmedatum", "date_shot": "Aufnahmedatum",
"exposure": "Belichtung", "exposure": "Belichtung",
"exposure_program": "Programm", "exposure_program": "Programm",
@ -299,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Freigabe hinzufügen", "add_share": "Freigabe hinzufügen",
"copy_link": "Link kopieren", "copy_link": "Link kopieren",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -61,6 +61,13 @@
"title": null "title": null
}, },
"title": null "title": null
},
"merge_people_groups": {
"description": "",
"destination_table": {
"title": ""
},
"title": ""
} }
}, },
"table": { "table": {
@ -115,6 +122,9 @@
"mega_byte_plural": null, "mega_byte_plural": null,
"tera_byte_plural": null "tera_byte_plural": null
} }
},
"media": {
"album": ""
} }
}, },
"album_filter": { "album_filter": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "Change label", "change_label": "Change label",
"detach_face": "Detach face", "detach_images": "Detach face",
"merge_face": "Merge face", "merge_people": "Merge face",
"move_faces": "Move faces" "move_faces": "Move faces"
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Download" "title": "Download"
}, },
"location": {
"title": "Location"
},
"media": { "media": {
"album_path": "Album path",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Action program", "action_program": "Action program",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Aperture", "aperture": "Aperture",
"camera": "Camera", "camera": "Camera",
"coordinates": "Coordinates",
"date_shot": "Date shot", "date_shot": "Date shot",
"exposure": "Exposure", "exposure": "Exposure",
"exposure_program": "Program", "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": { "sharing": {
"add_share": "Add shares", "add_share": "Add shares",
"copy_link": "Copy Link", "copy_link": "Copy Link",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "From today", "dropdown_all": "From today",
"dropdown_year": "{{year}} and earlier",
"label": "Date" "label": "Date"
} }
}, },

View File

@ -36,6 +36,15 @@
"select_image_faces": { "select_image_faces": {
"search_images_placeholder": "Search images..." "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": { "settings": {
@ -54,6 +63,9 @@
}, },
"sharing": { "sharing": {
"table_header": "Public shares" "table_header": "Public shares"
},
"media": {
"album": "Album"
} }
} }
} }

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Descargar" "title": "Descargar"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Programa de acción", "action_program": "Programa de acción",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Abertura", "aperture": "Abertura",
"camera": "Cámara", "camera": "Cámara",
"coordinates": "",
"date_shot": "Fecha de captura", "date_shot": "Fecha de captura",
"exposure": "Exposición", "exposure": "Exposición",
"exposure_program": "Programa", "exposure_program": "Programa",
@ -299,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Añadir compartido", "add_share": "Añadir compartido",
"copy_link": "Copiar enlace", "copy_link": "Copiar enlace",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -61,6 +61,13 @@
"title": null "title": null
}, },
"title": null "title": null
},
"merge_people_groups": {
"description": "",
"destination_table": {
"title": ""
},
"title": ""
} }
}, },
"table": { "table": {
@ -120,6 +127,9 @@
"mega_byte_plural": null, "mega_byte_plural": null,
"tera_byte_plural": null "tera_byte_plural": null
} }
},
"media": {
"album": ""
} }
}, },
"title": { "title": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "Changer le label", "change_label": "Changer le label",
"detach_face": "Détacher le visage", "detach_images": "Détacher le visage",
"merge_face": "Fusionner le visage", "merge_people": "Fusionner le visage",
"move_faces": "Déplacer les visages" "move_faces": "Déplacer les visages"
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Télécharger" "title": "Télécharger"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Programme d'action", "action_program": "Programme d'action",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Ouverture", "aperture": "Ouverture",
"camera": "Modèle", "camera": "Modèle",
"coordinates": "",
"date_shot": "Prise de vue", "date_shot": "Prise de vue",
"exposure": "Vitesse", "exposure": "Vitesse",
"exposure_program": "Programme", "exposure_program": "Programme",
@ -299,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Ajouter un partage", "add_share": "Ajouter un partage",
"copy_link": "Copier le lien", "copy_link": "Copier le lien",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "Depuis aujourd'hui", "dropdown_all": "Depuis aujourd'hui",
"dropdown_year": "",
"label": "Date" "label": "Date"
} }
}, },

View File

@ -60,6 +60,13 @@
"title": null "title": null
}, },
"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": { "table": {
@ -95,6 +102,9 @@
}, },
"sharing": { "sharing": {
"table_header": "Partages publics" "table_header": "Partages publics"
},
"media": {
"album": ""
} }
}, },
"title": { "title": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Download" "title": "Download"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Programma sport", "action_program": "Programma sport",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Apertura", "aperture": "Apertura",
"camera": "Fotocamera", "camera": "Fotocamera",
"coordinates": "",
"date_shot": "Data di scatto", "date_shot": "Data di scatto",
"exposure": "Esposizione", "exposure": "Esposizione",
"exposure_program": "Programma", "exposure_program": "Programma",
@ -299,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Aggiungi condivisione", "add_share": "Aggiungi condivisione",
"copy_link": "Copia il link", "copy_link": "Copia il link",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -49,6 +49,15 @@
}, },
"tableselect_image_faces": { "tableselect_image_faces": {
"search_images_placeholder": null "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": { "settings": {
@ -82,6 +91,9 @@
"mega_byte_plural": null, "mega_byte_plural": null,
"tera_byte_plural": null "tera_byte_plural": null
} }
},
"media": {
"album": ""
} }
}, },
"album_filter": { "album_filter": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -264,7 +264,11 @@
}, },
"title": "Pobierz" "title": "Pobierz"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Program działania", "action_program": "Program działania",
@ -293,6 +297,7 @@
"name": { "name": {
"aperture": "Przysłona", "aperture": "Przysłona",
"camera": "Aparat ", "camera": "Aparat ",
"coordinates": "",
"date_shot": "Data wykonania", "date_shot": "Data wykonania",
"exposure": "Ekspozycja", "exposure": "Ekspozycja",
"exposure_program": "Program", "exposure_program": "Program",
@ -304,6 +309,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Dodaj udział", "add_share": "Dodaj udział",
"copy_link": "Skopiuj link", "copy_link": "Skopiuj link",
@ -324,6 +338,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -61,6 +61,13 @@
"title": null "title": null
}, },
"title": null "title": null
},
"merge_people_groups": {
"description": "",
"destination_table": {
"title": ""
},
"title": ""
} }
}, },
"table": { "table": {
@ -137,6 +144,9 @@
"table_header": "Publiczne udostępnienia", "table_header": "Publiczne udostępnienia",
"delete": null, "delete": null,
"more": null "more": null
},
"media": {
"album": ""
} }
}, },
"title": { "title": {

View File

@ -61,17 +61,17 @@
"description": "Galeria de fotos simples e fácil de usar para servidores pessoais" "description": "Galeria de fotos simples e fácil de usar para servidores pessoais"
}, },
"people_page": { "people_page": {
"action_label": {
"change_label": "",
"detach_images": "",
"merge_people": "",
"move_faces": ""
},
"face_group": { "face_group": {
"label_placeholder": "Etiqueta", "label_placeholder": "Etiqueta",
"unlabeled": "Sem etiqueta", "unlabeled": "Sem etiqueta",
"unlabeled_person": "Pessoa sem etiqueta" "unlabeled_person": "Pessoa sem etiqueta"
}, },
"action_label": {
"change_label": null,
"merge_face": null,
"detach_face": null,
"move_faces": null
},
"modal": { "modal": {
"action": { "action": {
"merge": "Juntar" "merge": "Juntar"
@ -215,6 +215,27 @@
}, },
"sidebar": { "sidebar": {
"album": { "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" "title_placeholder": "Título do Álbum"
}, },
"download": { "download": {
@ -238,7 +259,11 @@
}, },
"title": "Descarregar" "title": "Descarregar"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Programa de ação", "action_program": "Programa de ação",
@ -267,6 +292,7 @@
"name": { "name": {
"aperture": "Abertura", "aperture": "Abertura",
"camera": "Câmera", "camera": "Câmera",
"coordinates": "",
"date_shot": "Data da foto", "date_shot": "Data da foto",
"exposure": "Exposição", "exposure": "Exposição",
"exposure_program": "Programa", "exposure_program": "Programa",
@ -278,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Adicionar partilha", "add_share": "Adicionar partilha",
"copy_link": "Copiar link", "copy_link": "Copiar link",
@ -295,6 +330,13 @@
"places": "Locais", "places": "Locais",
"settings": "Configurações" "settings": "Configurações"
}, },
"timeline_filter": {
"date": {
"dropdown_all": "",
"dropdown_year": "",
"label": ""
}
},
"title": { "title": {
"loading_album": "Carregar Álbum", "loading_album": "Carregar Álbum",
"login": "Login", "login": "Login",

View File

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

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -264,7 +264,11 @@
}, },
"title": "Скачать" "title": "Скачать"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Действие программа", "action_program": "Действие программа",
@ -293,6 +297,7 @@
"name": { "name": {
"aperture": "Диафрагма", "aperture": "Диафрагма",
"camera": "Камера", "camera": "Камера",
"coordinates": "",
"date_shot": "Дата снимка", "date_shot": "Дата снимка",
"exposure": "Экспозиция", "exposure": "Экспозиция",
"exposure_program": "Программа", "exposure_program": "Программа",
@ -304,6 +309,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Поделится", "add_share": "Поделится",
"copy_link": "Скопировать ссылку", "copy_link": "Скопировать ссылку",
@ -324,6 +338,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -49,6 +49,15 @@
}, },
"tableselect_image_faces": { "tableselect_image_faces": {
"search_images_placeholder": null "search_images_placeholder": null
},
"modal": {
"merge_people_groups": {
"description": "Все фотографии в этой группе лиц будут обьеденены с выбранной группой.",
"destination_table": {
"title": "Выберете конечное лицо"
},
"title": "Обьединить группы лиц"
}
} }
}, },
"settings": { "settings": {
@ -99,6 +108,9 @@
"table_header": "Общий доступ", "table_header": "Общий доступ",
"delete": null, "delete": null,
"more": null "more": null
},
"media": {
"album": ""
} }
}, },
"album_filter": { "album_filter": {

View File

@ -63,8 +63,8 @@
"people_page": { "people_page": {
"action_label": { "action_label": {
"change_label": "", "change_label": "",
"detach_face": "", "detach_images": "",
"merge_face": "", "merge_people": "",
"move_faces": "" "move_faces": ""
}, },
"face_group": { "face_group": {
@ -259,7 +259,11 @@
}, },
"title": "Ladda ner" "title": "Ladda ner"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "Actionprogram", "action_program": "Actionprogram",
@ -288,6 +292,7 @@
"name": { "name": {
"aperture": "Bländare", "aperture": "Bländare",
"camera": "Kamera", "camera": "Kamera",
"coordinates": "",
"date_shot": "Datum tagen", "date_shot": "Datum tagen",
"exposure": "Exponering", "exposure": "Exponering",
"exposure_program": "Program", "exposure_program": "Program",
@ -299,6 +304,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "Dela", "add_share": "Dela",
"copy_link": "Kopiera länk", "copy_link": "Kopiera länk",
@ -319,6 +333,7 @@
"timeline_filter": { "timeline_filter": {
"date": { "date": {
"dropdown_all": "", "dropdown_all": "",
"dropdown_year": "",
"label": "" "label": ""
} }
}, },

View File

@ -61,6 +61,13 @@
"title": null "title": null
}, },
"title": null "title": null
},
"merge_people_groups": {
"description": "",
"destination_table": {
"title": ""
},
"title": ""
} }
}, },
"table": { "table": {
@ -123,6 +130,9 @@
"mega_byte_plural": null, "mega_byte_plural": null,
"tera_byte_plural": null "tera_byte_plural": null
} }
},
"media": {
"album": ""
} }
}, },
"title": { "title": {

View File

@ -61,17 +61,17 @@
"description": "简单及易用的照片库给个人服务器" "description": "简单及易用的照片库给个人服务器"
}, },
"people_page": { "people_page": {
"action_label": {
"change_label": "",
"detach_images": "",
"merge_people": "",
"move_faces": ""
},
"face_group": { "face_group": {
"label_placeholder": "名称", "label_placeholder": "名称",
"unlabeled": "未命名", "unlabeled": "未命名",
"unlabeled_person": "未命名人物" "unlabeled_person": "未命名人物"
}, },
"action_label": {
"change_label": null,
"merge_face": null,
"detach_face": null,
"move_faces": null
},
"modal": { "modal": {
"action": { "action": {
"merge": "合并" "merge": "合并"
@ -215,30 +215,36 @@
}, },
"sidebar": { "sidebar": {
"album": { "album": {
"album_cover": "",
"download": {
"high-resolutions": {
"description": "",
"title": ""
},
"originals": {
"description": "",
"title": ""
},
"thumbnails": {
"description": "",
"title": ""
},
"web-videos": {
"description": "",
"title": ""
}
},
"reset_cover": "",
"set_cover": "",
"title_placeholder": "相册名称" "title_placeholder": "相册名称"
}, },
"download": { "download": {
"filesize": { "filesize": {
"byte": null, "byte": "",
"giga_byte": null, "giga_byte": "",
"kilo_byte": null, "kilo_byte": "",
"mega_byte": null, "mega_byte": "",
"tera_byte": null, "tera_byte": ""
"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
}, },
"table_columns": { "table_columns": {
"dimensions": "尺寸", "dimensions": "尺寸",
@ -248,7 +254,11 @@
}, },
"title": "下载" "title": "下载"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "动态模式", "action_program": "动态模式",
@ -264,19 +274,20 @@
}, },
"flash": { "flash": {
"auto": "自动", "auto": "自动",
"did_not_fire": null, "did_not_fire": "",
"fired": null, "fired": "",
"no_flash": null, "no_flash": "",
"no_flash_function": null, "no_flash_function": "",
"off": "关闭", "off": "关闭",
"on": "使用", "on": "使用",
"red_eye_reduction": "减轻红眼", "red_eye_reduction": "减轻红眼",
"return_detected": null, "return_detected": "",
"return_not_detected": null "return_not_detected": ""
}, },
"name": { "name": {
"aperture": "光圈", "aperture": "光圈",
"camera": "相机", "camera": "相机",
"coordinates": "",
"date_shot": "拍摄日期", "date_shot": "拍摄日期",
"exposure": "曝光", "exposure": "曝光",
"exposure_program": "模式", "exposure_program": "模式",
@ -288,6 +299,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "新增分享", "add_share": "新增分享",
"copy_link": "复制链接", "copy_link": "复制链接",
@ -305,6 +325,13 @@
"places": "地点", "places": "地点",
"settings": "设置" "settings": "设置"
}, },
"timeline_filter": {
"date": {
"dropdown_all": "",
"dropdown_year": "",
"label": ""
}
},
"title": { "title": {
"loading_album": "载入相册", "loading_album": "载入相册",
"login": "登入", "login": "登入",

View File

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

View File

@ -61,17 +61,17 @@
"description": "簡單及易用的照片庫給個人伺服器" "description": "簡單及易用的照片庫給個人伺服器"
}, },
"people_page": { "people_page": {
"action_label": {
"change_label": "",
"detach_images": "",
"merge_people": "",
"move_faces": ""
},
"face_group": { "face_group": {
"label_placeholder": "名稱", "label_placeholder": "名稱",
"unlabeled": "未命名", "unlabeled": "未命名",
"unlabeled_person": "未命名人物" "unlabeled_person": "未命名人物"
}, },
"action_label": {
"change_label": null,
"merge_face": null,
"detach_face": null,
"move_faces": null
},
"modal": { "modal": {
"action": { "action": {
"merge": "合併" "merge": "合併"
@ -215,30 +215,36 @@
}, },
"sidebar": { "sidebar": {
"album": { "album": {
"album_cover": "",
"download": {
"high-resolutions": {
"description": "",
"title": ""
},
"originals": {
"description": "",
"title": ""
},
"thumbnails": {
"description": "",
"title": ""
},
"web-videos": {
"description": "",
"title": ""
}
},
"reset_cover": "",
"set_cover": "",
"title_placeholder": "相簿名稱" "title_placeholder": "相簿名稱"
}, },
"download": { "download": {
"filesize": { "filesize": {
"byte": null, "byte": "",
"giga_byte": null, "giga_byte": "",
"kilo_byte": null, "kilo_byte": "",
"mega_byte": null, "mega_byte": "",
"tera_byte": null, "tera_byte": ""
"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
}, },
"table_columns": { "table_columns": {
"dimensions": "尺寸", "dimensions": "尺寸",
@ -248,7 +254,11 @@
}, },
"title": "下載" "title": "下載"
}, },
"location": {
"title": ""
},
"media": { "media": {
"album_path": "",
"exif": { "exif": {
"exposure_program": { "exposure_program": {
"action_program": "動態模式", "action_program": "動態模式",
@ -264,19 +274,20 @@
}, },
"flash": { "flash": {
"auto": "自動", "auto": "自動",
"did_not_fire": null, "did_not_fire": "",
"fired": null, "fired": "",
"no_flash": null, "no_flash": "",
"no_flash_function": null, "no_flash_function": "",
"off": "關閉", "off": "關閉",
"on": "使用", "on": "使用",
"red_eye_reduction": "減輕紅眼", "red_eye_reduction": "減輕紅眼",
"return_detected": null, "return_detected": "",
"return_not_detected": null "return_not_detected": ""
}, },
"name": { "name": {
"aperture": "光圈", "aperture": "光圈",
"camera": "相機", "camera": "相機",
"coordinates": "",
"date_shot": "拍攝日期", "date_shot": "拍攝日期",
"exposure": "曝光", "exposure": "曝光",
"exposure_program": "模式", "exposure_program": "模式",
@ -288,6 +299,15 @@
} }
} }
}, },
"people": {
"action_label": {
"detach_image": "",
"merge_face": "",
"move_face": ""
},
"confirm_image_detach": "",
"title": ""
},
"sharing": { "sharing": {
"add_share": "新增分享", "add_share": "新增分享",
"copy_link": "複製連結", "copy_link": "複製連結",
@ -305,6 +325,13 @@
"places": "地點", "places": "地點",
"settings": "設定" "settings": "設定"
}, },
"timeline_filter": {
"date": {
"dropdown_all": "",
"dropdown_year": "",
"label": ""
}
},
"title": { "title": {
"loading_album": "載入相簿", "loading_album": "載入相簿",
"login": "登入", "login": "登入",

View File

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

View File

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

View File

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