Merge pull request #554 from photoview/viktorstrate/issue319
Add faces and map to media sidebar
This commit is contained in:
commit
72cb02d981
|
@ -60,6 +60,8 @@ models:
|
|||
fields:
|
||||
faceGroup:
|
||||
resolver: true
|
||||
media:
|
||||
resolver: true
|
||||
FaceRectangle:
|
||||
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
|
||||
SiteInfo:
|
||||
|
|
|
@ -75,6 +75,11 @@ type ComplexityRoot struct {
|
|||
Token func(childComplexity int) int
|
||||
}
|
||||
|
||||
Coordinates struct {
|
||||
Latitude func(childComplexity int) int
|
||||
Longitude func(childComplexity int) int
|
||||
}
|
||||
|
||||
FaceGroup struct {
|
||||
ID func(childComplexity int) int
|
||||
ImageFaceCount func(childComplexity int) int
|
||||
|
@ -122,6 +127,7 @@ type ComplexityRoot struct {
|
|||
MediaExif struct {
|
||||
Aperture func(childComplexity int) int
|
||||
Camera func(childComplexity int) int
|
||||
Coordinates func(childComplexity int) int
|
||||
DateShot func(childComplexity int) int
|
||||
Exposure func(childComplexity int) int
|
||||
ExposureProgram func(childComplexity int) int
|
||||
|
@ -282,6 +288,8 @@ type FaceGroupResolver interface {
|
|||
ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error)
|
||||
}
|
||||
type ImageFaceResolver interface {
|
||||
Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error)
|
||||
|
||||
FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error)
|
||||
}
|
||||
type MediaResolver interface {
|
||||
|
@ -473,6 +481,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
|||
|
||||
return e.complexity.AuthorizeResult.Token(childComplexity), true
|
||||
|
||||
case "Coordinates.latitude":
|
||||
if e.complexity.Coordinates.Latitude == nil {
|
||||
break
|
||||
}
|
||||
|
||||
return e.complexity.Coordinates.Latitude(childComplexity), true
|
||||
|
||||
case "Coordinates.longitude":
|
||||
if e.complexity.Coordinates.Longitude == nil {
|
||||
break
|
||||
}
|
||||
|
||||
return e.complexity.Coordinates.Longitude(childComplexity), true
|
||||
|
||||
case "FaceGroup.id":
|
||||
if e.complexity.FaceGroup.ID == nil {
|
||||
break
|
||||
|
@ -695,6 +717,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
|||
|
||||
return e.complexity.MediaExif.Camera(childComplexity), true
|
||||
|
||||
case "MediaEXIF.coordinates":
|
||||
if e.complexity.MediaExif.Coordinates == nil {
|
||||
break
|
||||
}
|
||||
|
||||
return e.complexity.MediaExif.Coordinates(childComplexity), true
|
||||
|
||||
case "MediaEXIF.dateShot":
|
||||
if e.complexity.MediaExif.DateShot == nil {
|
||||
break
|
||||
|
@ -1745,7 +1774,7 @@ type Query {
|
|||
"Get media owned by the logged in user, returned in GeoJson format"
|
||||
myMediaGeoJson: Any! @isAuthorized
|
||||
"Get the mapbox api token, returns null if mapbox is not enabled"
|
||||
mapboxToken: String @isAuthorized
|
||||
mapboxToken: String
|
||||
|
||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||
|
@ -1955,8 +1984,6 @@ type Album {
|
|||
path: [Album!]!
|
||||
|
||||
shares: [ShareToken!]!
|
||||
|
||||
#coverID: Int
|
||||
}
|
||||
|
||||
type MediaURL {
|
||||
|
@ -2029,6 +2056,15 @@ type MediaEXIF {
|
|||
flash: Int
|
||||
"An index describing the mode for adjusting the exposure of the image"
|
||||
exposureProgram: Int
|
||||
"GPS coordinates of where the image was taken"
|
||||
coordinates: Coordinates
|
||||
}
|
||||
|
||||
type Coordinates {
|
||||
"GPS latitude in degrees"
|
||||
latitude: Float!
|
||||
"GPS longitude in degrees"
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
type VideoMetadata {
|
||||
|
@ -3459,6 +3495,76 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr
|
|||
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _Coordinates_latitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ec.Error(ctx, ec.Recover(ctx, r))
|
||||
ret = graphql.Null
|
||||
}
|
||||
}()
|
||||
fc := &graphql.FieldContext{
|
||||
Object: "Coordinates",
|
||||
Field: field,
|
||||
Args: nil,
|
||||
IsMethod: false,
|
||||
IsResolver: false,
|
||||
}
|
||||
|
||||
ctx = graphql.WithFieldContext(ctx, fc)
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return obj.Latitude, nil
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
return graphql.Null
|
||||
}
|
||||
if resTmp == nil {
|
||||
if !graphql.HasFieldError(ctx, fc) {
|
||||
ec.Errorf(ctx, "must not be null")
|
||||
}
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(float64)
|
||||
fc.Result = res
|
||||
return ec.marshalNFloat2float64(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _Coordinates_longitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ec.Error(ctx, ec.Recover(ctx, r))
|
||||
ret = graphql.Null
|
||||
}
|
||||
}()
|
||||
fc := &graphql.FieldContext{
|
||||
Object: "Coordinates",
|
||||
Field: field,
|
||||
Args: nil,
|
||||
IsMethod: false,
|
||||
IsResolver: false,
|
||||
}
|
||||
|
||||
ctx = graphql.WithFieldContext(ctx, fc)
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return obj.Longitude, nil
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
return graphql.Null
|
||||
}
|
||||
if resTmp == nil {
|
||||
if !graphql.HasFieldError(ctx, fc) {
|
||||
ec.Errorf(ctx, "must not be null")
|
||||
}
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(float64)
|
||||
fc.Result = res
|
||||
return ec.marshalNFloat2float64(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
@ -3789,14 +3895,14 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
|
|||
Object: "ImageFace",
|
||||
Field: field,
|
||||
Args: nil,
|
||||
IsMethod: false,
|
||||
IsResolver: false,
|
||||
IsMethod: true,
|
||||
IsResolver: true,
|
||||
}
|
||||
|
||||
ctx = graphql.WithFieldContext(ctx, fc)
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return obj.Media, nil
|
||||
return ec.resolvers.ImageFace().Media(rctx, obj)
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
|
@ -3808,9 +3914,9 @@ func (ec *executionContext) _ImageFace_media(ctx context.Context, field graphql.
|
|||
}
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(models.Media)
|
||||
res := resTmp.(*models.Media)
|
||||
fc.Result = res
|
||||
return ec.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
|
||||
return ec.marshalNMedia2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) {
|
||||
|
@ -4853,6 +4959,38 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel
|
|||
return ec.marshalOInt2ᚖint64(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _MediaEXIF_coordinates(ctx context.Context, field graphql.CollectedField, obj *models.MediaEXIF) (ret graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ec.Error(ctx, ec.Recover(ctx, r))
|
||||
ret = graphql.Null
|
||||
}
|
||||
}()
|
||||
fc := &graphql.FieldContext{
|
||||
Object: "MediaEXIF",
|
||||
Field: field,
|
||||
Args: nil,
|
||||
IsMethod: true,
|
||||
IsResolver: false,
|
||||
}
|
||||
|
||||
ctx = graphql.WithFieldContext(ctx, fc)
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return obj.Coordinates(), nil
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
return graphql.Null
|
||||
}
|
||||
if resTmp == nil {
|
||||
return graphql.Null
|
||||
}
|
||||
res := resTmp.(*models.Coordinates)
|
||||
fc.Result = res
|
||||
return ec.marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx, field.Selections, res)
|
||||
}
|
||||
|
||||
func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
@ -7283,28 +7421,8 @@ func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphq
|
|||
|
||||
ctx = graphql.WithFieldContext(ctx, fc)
|
||||
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||
directive0 := func(rctx context.Context) (interface{}, error) {
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return ec.resolvers.Query().MapboxToken(rctx)
|
||||
}
|
||||
directive1 := func(ctx context.Context) (interface{}, error) {
|
||||
if ec.directives.IsAuthorized == nil {
|
||||
return nil, errors.New("directive isAuthorized is not implemented")
|
||||
}
|
||||
return ec.directives.IsAuthorized(ctx, nil, directive0)
|
||||
}
|
||||
|
||||
tmp, err := directive1(rctx)
|
||||
if err != nil {
|
||||
return nil, graphql.ErrorOnPath(ctx, err)
|
||||
}
|
||||
if tmp == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if data, ok := tmp.(*string); ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp)
|
||||
ctx = rctx // use context from middleware stack in children
|
||||
return ec.resolvers.Query().MapboxToken(rctx)
|
||||
})
|
||||
if err != nil {
|
||||
ec.Error(ctx, err)
|
||||
|
@ -10429,6 +10547,38 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select
|
|||
return out
|
||||
}
|
||||
|
||||
var coordinatesImplementors = []string{"Coordinates"}
|
||||
|
||||
func (ec *executionContext) _Coordinates(ctx context.Context, sel ast.SelectionSet, obj *models.Coordinates) graphql.Marshaler {
|
||||
fields := graphql.CollectFields(ec.OperationContext, sel, coordinatesImplementors)
|
||||
|
||||
out := graphql.NewFieldSet(fields)
|
||||
var invalids uint32
|
||||
for i, field := range fields {
|
||||
switch field.Name {
|
||||
case "__typename":
|
||||
out.Values[i] = graphql.MarshalString("Coordinates")
|
||||
case "latitude":
|
||||
out.Values[i] = ec._Coordinates_latitude(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
invalids++
|
||||
}
|
||||
case "longitude":
|
||||
out.Values[i] = ec._Coordinates_longitude(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
invalids++
|
||||
}
|
||||
default:
|
||||
panic("unknown field " + strconv.Quote(field.Name))
|
||||
}
|
||||
}
|
||||
out.Dispatch()
|
||||
if invalids > 0 {
|
||||
return graphql.Null
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var faceGroupImplementors = []string{"FaceGroup"}
|
||||
|
||||
func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler {
|
||||
|
@ -10545,10 +10695,19 @@ func (ec *executionContext) _ImageFace(ctx context.Context, sel ast.SelectionSet
|
|||
atomic.AddUint32(&invalids, 1)
|
||||
}
|
||||
case "media":
|
||||
out.Values[i] = ec._ImageFace_media(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
atomic.AddUint32(&invalids, 1)
|
||||
}
|
||||
field := field
|
||||
out.Concurrently(i, func() (res graphql.Marshaler) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ec.Error(ctx, ec.Recover(ctx, r))
|
||||
}
|
||||
}()
|
||||
res = ec._ImageFace_media(ctx, field, obj)
|
||||
if res == graphql.Null {
|
||||
atomic.AddUint32(&invalids, 1)
|
||||
}
|
||||
return res
|
||||
})
|
||||
case "rectangle":
|
||||
out.Values[i] = ec._ImageFace_rectangle(ctx, field, obj)
|
||||
if out.Values[i] == graphql.Null {
|
||||
|
@ -10824,6 +10983,8 @@ func (ec *executionContext) _MediaEXIF(ctx context.Context, sel ast.SelectionSet
|
|||
out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj)
|
||||
case "exposureProgram":
|
||||
out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj)
|
||||
case "coordinates":
|
||||
out.Values[i] = ec._MediaEXIF_coordinates(ctx, field, obj)
|
||||
default:
|
||||
panic("unknown field " + strconv.Quote(field.Name))
|
||||
}
|
||||
|
@ -12875,6 +13036,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast
|
|||
return graphql.MarshalBoolean(*v)
|
||||
}
|
||||
|
||||
func (ec *executionContext) marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx context.Context, sel ast.SelectionSet, v *models.Coordinates) graphql.Marshaler {
|
||||
if v == nil {
|
||||
return graphql.Null
|
||||
}
|
||||
return ec._Coordinates(ctx, sel, v)
|
||||
}
|
||||
|
||||
func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
|
|
|
@ -31,6 +31,19 @@ type ImageFace struct {
|
|||
Rectangle FaceRectangle `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (f *ImageFace) FillMedia(db *gorm.DB) error {
|
||||
if f.Media.ID != 0 {
|
||||
// media already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Model(&f).Association("Media").Find(&f.Media); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type FaceDescriptor face.Descriptor
|
||||
|
||||
// GormDataType datatype used in database
|
||||
|
|
|
@ -15,6 +15,13 @@ type AuthorizeResult struct {
|
|||
Token *string `json:"token"`
|
||||
}
|
||||
|
||||
type Coordinates struct {
|
||||
// GPS latitude in degrees
|
||||
Latitude float64 `json:"latitude"`
|
||||
// GPS longitude in degrees
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type MediaDownload struct {
|
||||
Title string `json:"title"`
|
||||
MediaURL *MediaURL `json:"mediaUrl"`
|
||||
|
|
|
@ -28,3 +28,14 @@ func (MediaEXIF) TableName() string {
|
|||
func (exif *MediaEXIF) Media() *Media {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (exif *MediaEXIF) Coordinates() *Coordinates {
|
||||
if exif.GPSLatitude == nil || exif.GPSLongitude == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Coordinates{
|
||||
Latitude: *exif.GPSLatitude,
|
||||
Longitude: *exif.GPSLongitude,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,14 @@ func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace)
|
|||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
func (r imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
||||
if err := obj.FillMedia(r.Database); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &obj.Media, nil
|
||||
}
|
||||
|
||||
func (r faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
|
|
|
@ -76,7 +76,7 @@ type Query {
|
|||
"Get media owned by the logged in user, returned in GeoJson format"
|
||||
myMediaGeoJson: Any! @isAuthorized
|
||||
"Get the mapbox api token, returns null if mapbox is not enabled"
|
||||
mapboxToken: String @isAuthorized
|
||||
mapboxToken: String
|
||||
|
||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||
|
@ -358,6 +358,15 @@ type MediaEXIF {
|
|||
flash: Int
|
||||
"An index describing the mode for adjusting the exposure of the image"
|
||||
exposureProgram: Int
|
||||
"GPS coordinates of where the image was taken"
|
||||
coordinates: Coordinates
|
||||
}
|
||||
|
||||
type Coordinates {
|
||||
"GPS latitude in degrees"
|
||||
latitude: Float!
|
||||
"GPS longitude in degrees"
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
type VideoMetadata {
|
||||
|
|
|
@ -1,7 +1,20 @@
|
|||
module.exports = {
|
||||
skipDefaultValues: locale => locale != 'en',
|
||||
sort: true,
|
||||
locales: ['da', 'de', 'en', 'es', 'fr', 'it', 'pl', 'ru', 'sv'],
|
||||
locales: [
|
||||
'da',
|
||||
'de',
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
'sv',
|
||||
'zh-CN',
|
||||
'zh-HK',
|
||||
],
|
||||
input: 'src/**/*.{js,ts,jsx,tsx}',
|
||||
output: 'src/extractedTranslations/$LOCALE/$NAMESPACE.json',
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"@apollo/client": "^3.3.21",
|
||||
"@babel/preset-typescript": "^7.14.5",
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@headlessui/react": "^1.4.1",
|
||||
"@react-aria/focus": "^3.4.0",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
"@types/geojson": "^7946.0.8",
|
||||
|
@ -49,6 +49,7 @@
|
|||
"react-test-renderer": "^17.0.2",
|
||||
"styled-components": "^5.3.0",
|
||||
"subscriptions-transport-ws": "^0.9.19",
|
||||
"tailwind-override": "^0.2.3",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"typescript": "^4.3.5",
|
||||
"url-join": "^4.0.1"
|
||||
|
@ -2102,9 +2103,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz",
|
||||
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz",
|
||||
"integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
@ -24041,6 +24042,11 @@
|
|||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-override": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-override/-/tailwind-override-0.2.3.tgz",
|
||||
"integrity": "sha512-psWRqXL3TiI2h/YtzRq7dwKO6N7CrsEs4v99rNHgqEclfx4IioM0cHZ9O6pzerV3E6bZi6DhCbeq0z67Xs5PIQ=="
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"name": "@tailwindcss/postcss7-compat",
|
||||
"version": "2.2.4",
|
||||
|
@ -28796,9 +28802,9 @@
|
|||
}
|
||||
},
|
||||
"@headlessui/react": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.3.0.tgz",
|
||||
"integrity": "sha512-2gqTO6BQ3Jr8vDX1B67n1gl6MGKTt6DBmR+H0qxwj0gTMnR2+Qpktj8alRWxsZBODyOiBb77QSQpE/6gG3MX4Q==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.4.1.tgz",
|
||||
"integrity": "sha512-gL6Ns5xQM57cZBzX6IVv6L7nsam8rDEpRhs5fg28SN64ikfmuuMgunc+Rw5C1cMScnvFM+cz32ueVrlSFEVlSg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
|
@ -46037,6 +46043,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"tailwind-override": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-override/-/tailwind-override-0.2.3.tgz",
|
||||
"integrity": "sha512-psWRqXL3TiI2h/YtzRq7dwKO6N7CrsEs4v99rNHgqEclfx4IioM0cHZ9O6pzerV3E6bZi6DhCbeq0z67Xs5PIQ=="
|
||||
},
|
||||
"tailwindcss": {
|
||||
"version": "npm:@tailwindcss/postcss7-compat@2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.4.tgz",
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"@apollo/client": "^3.3.21",
|
||||
"@babel/preset-typescript": "^7.14.5",
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@headlessui/react": "^1.4.1",
|
||||
"@react-aria/focus": "^3.4.0",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
"@types/geojson": "^7946.0.8",
|
||||
|
@ -49,6 +49,7 @@
|
|||
"react-test-renderer": "^17.0.2",
|
||||
"styled-components": "^5.3.0",
|
||||
"subscriptions-transport-ws": "^0.9.19",
|
||||
"tailwind-override": "^0.2.3",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"typescript": "^4.3.5",
|
||||
"url-join": "^4.0.1"
|
||||
|
@ -63,7 +64,7 @@
|
|||
"lint:types": "tsc --noemit",
|
||||
"jest": "craco test --setupFilesAfterEnv ./testing/setupTests.ts",
|
||||
"jest:ci": "CI=true craco test --setupFilesAfterEnv ./testing/setupTests.ts --verbose --ci --coverage",
|
||||
"genSchemaTypes": "apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts",
|
||||
"genSchemaTypes": "apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts && prettier --write */**/__generated__/*.ts",
|
||||
"extractTranslations": "i18next -c i18next-parser.config.js",
|
||||
"prepare": "(cd .. && npx husky install)"
|
||||
},
|
||||
|
@ -71,12 +72,12 @@
|
|||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@testing-library/user-event": "^13.1.9",
|
||||
"apollo": "2.33.4",
|
||||
"apollo-language-server": "1.26.3",
|
||||
"husky": "^6.0.0",
|
||||
"i18next-parser": "^4.2.0",
|
||||
"lint-staged": "^11.0.1",
|
||||
"tsc-files": "^1.1.2",
|
||||
"apollo": "2.33.4",
|
||||
"apollo-language-server": "1.26.3"
|
||||
"tsc-files": "^1.1.2"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5",
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import PeoplePage, {
|
||||
FaceDetails,
|
||||
FaceGroup,
|
||||
MY_FACES_QUERY,
|
||||
SET_GROUP_LABEL_MUTATION,
|
||||
} from './PeoplePage'
|
||||
|
@ -144,7 +145,11 @@ describe('FaceDetails component', () => {
|
|||
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails group={emptyFaceGroup} />
|
||||
<FaceDetails
|
||||
editLabel={false}
|
||||
setEditLabel={jest.fn()}
|
||||
group={emptyFaceGroup}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
|
@ -159,7 +164,11 @@ describe('FaceDetails component', () => {
|
|||
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails group={labeledFaceGroup} />
|
||||
<FaceDetails
|
||||
editLabel={false}
|
||||
setEditLabel={jest.fn()}
|
||||
group={labeledFaceGroup}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
|
@ -190,7 +199,9 @@ describe('FaceDetails component', () => {
|
|||
]
|
||||
render(
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<FaceDetails group={faceGroup} />
|
||||
<MemoryRouter>
|
||||
<FaceGroup group={faceGroup} />
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
|
@ -211,4 +222,30 @@ describe('FaceDetails component', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
myFaces_myFaceGroups,
|
||||
} from './__generated__/myFaces'
|
||||
import { recognizeUnlabeledFaces } from './__generated__/recognizeUnlabeledFaces'
|
||||
import { tailwindClassNames } from '../../helpers/utils'
|
||||
|
||||
export const MY_FACES_QUERY = gql`
|
||||
query myFaces($limit: Int, $offset: Int) {
|
||||
|
@ -65,15 +66,8 @@ const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const FaceDetailsWrapper = styled.div<{ labeled: boolean }>`
|
||||
const FaceDetailsWrapper = styled.span<{ labeled: boolean }>`
|
||||
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
|
||||
width: 150px;
|
||||
margin: 12px auto 24px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
|
@ -82,12 +76,26 @@ const FaceDetailsWrapper = styled.div<{ labeled: boolean }>`
|
|||
`
|
||||
|
||||
type FaceDetailsProps = {
|
||||
group: myFaces_myFaceGroups
|
||||
group: {
|
||||
__typename: 'FaceGroup'
|
||||
id: string
|
||||
label: string | null
|
||||
imageFaceCount: number
|
||||
}
|
||||
className?: string
|
||||
textFieldClassName?: string
|
||||
editLabel: boolean
|
||||
setEditLabel: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
||||
export const FaceDetails = ({
|
||||
group,
|
||||
className,
|
||||
textFieldClassName,
|
||||
editLabel,
|
||||
setEditLabel,
|
||||
}: FaceDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
|
@ -126,11 +134,15 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
|||
if (!editLabel) {
|
||||
label = (
|
||||
<FaceDetailsWrapper
|
||||
className={tailwindClassNames(
|
||||
className,
|
||||
'whitespace-nowrap inline-block overflow-hidden overflow-clip'
|
||||
)}
|
||||
labeled={!!group.label}
|
||||
onClick={() => setEditLabel(true)}
|
||||
>
|
||||
<FaceImagesCount>{group.imageFaceCount}</FaceImagesCount>
|
||||
<button>
|
||||
<button className="">
|
||||
{group.label ?? t('people_page.face_group.unlabeled', 'Unlabeled')}
|
||||
</button>
|
||||
{/* <EditIcon name="pencil" /> */}
|
||||
|
@ -138,9 +150,9 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
|||
)
|
||||
} else {
|
||||
label = (
|
||||
<FaceDetailsWrapper labeled={!!group.label}>
|
||||
<FaceDetailsWrapper className={className} labeled={!!group.label}>
|
||||
<TextField
|
||||
className="w-[160px]"
|
||||
className={textFieldClassName}
|
||||
loading={loading}
|
||||
ref={inputRef}
|
||||
// size="mini"
|
||||
|
@ -181,15 +193,22 @@ type FaceGroupProps = {
|
|||
group: myFaces_myFaceGroups
|
||||
}
|
||||
|
||||
const FaceGroup = ({ group }: FaceGroupProps) => {
|
||||
export const FaceGroup = ({ group }: FaceGroupProps) => {
|
||||
const previewFace = group.imageFaces[0]
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ margin: '12px' }}>
|
||||
<Link to={`/people/${group.id}`}>
|
||||
<FaceCircleImage imageFace={previewFace} selectable />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import { BaseMutationOptions, gql, useMutation } from '@apollo/client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
@ -28,16 +28,50 @@ const DETACH_IMAGE_FACES_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
export const useDetachImageFaces = (
|
||||
mutationOptions: BaseMutationOptions<
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables
|
||||
>
|
||||
) => {
|
||||
const [detachImageFacesMutation] = useMutation<
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables
|
||||
>(DETACH_IMAGE_FACES_MUTATION, mutationOptions)
|
||||
|
||||
return async (
|
||||
selectedImageFaces: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
) => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
const result = await detachImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
type DetachImageFacesModalProps = {
|
||||
open: boolean
|
||||
setOpen(open: boolean): void
|
||||
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
|
||||
selectedImageFaces?: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
}
|
||||
|
||||
const DetachImageFacesModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
faceGroup,
|
||||
selectedImageFaces: selectedImageFacesProp,
|
||||
}: DetachImageFacesModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
@ -46,10 +80,7 @@ const DetachImageFacesModal = ({
|
|||
>([])
|
||||
const history = useHistory()
|
||||
|
||||
const [detachImageFacesMutation] = useMutation<
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables
|
||||
>(DETACH_IMAGE_FACES_MUTATION, {
|
||||
const detachImageFacesMutation = useDetachImageFaces({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
|
@ -57,6 +88,19 @@ const DetachImageFacesModal = ({
|
|||
],
|
||||
})
|
||||
|
||||
const detachImageFaces = () => {
|
||||
detachImageFacesMutation(selectedImageFaces).then(({ data }) => {
|
||||
if (isNil(data)) throw new Error('Expected data not to be null')
|
||||
setOpen(false)
|
||||
history.push(`/people/${data.detachImageFaces.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isNil(selectedImageFacesProp)) return
|
||||
setSelectedImageFaces(selectedImageFacesProp)
|
||||
}, [selectedImageFacesProp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedImageFaces([])
|
||||
|
@ -65,20 +109,6 @@ const DetachImageFacesModal = ({
|
|||
|
||||
if (open == false) return null
|
||||
|
||||
const detachImageFaces = () => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
detachImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
if (isNil(data)) throw new Error('Expected data not to be null')
|
||||
setOpen(false)
|
||||
history.push(`/people/${data.detachImageFaces.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const imageFaces = faceGroup?.imageFaces ?? []
|
||||
|
||||
return (
|
||||
|
@ -94,7 +124,7 @@ const DetachImageFacesModal = ({
|
|||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: 'Cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
{
|
||||
|
|
|
@ -8,7 +8,7 @@ import React, {
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import { Button, TextField } from '../../../primitives/form/Input'
|
||||
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import { MY_FACES_QUERY, SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import {
|
||||
setGroupLabel,
|
||||
setGroupLabelVariables,
|
||||
|
@ -112,6 +112,11 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
|||
open={mergeModalOpen}
|
||||
setOpen={setMergeModalOpen}
|
||||
sourceFaceGroup={faceGroup}
|
||||
refetchQueries={[
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<MoveImageFacesModal
|
||||
open={moveModalOpen}
|
||||
|
@ -133,16 +138,24 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
|||
<div className="mb-2">{title}</div>
|
||||
<ul className="flex gap-2 flex-wrap mb-6">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { gql, PureQueryOptions, useMutation, useQuery } from '@apollo/client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
@ -31,18 +31,24 @@ const COMBINE_FACES_MUTATION = gql`
|
|||
type MergeFaceGroupsModalProps = {
|
||||
open: boolean
|
||||
setOpen(open: boolean): void
|
||||
sourceFaceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
|
||||
sourceFaceGroup: {
|
||||
__typename: 'FaceGroup'
|
||||
id: string
|
||||
}
|
||||
refetchQueries: PureQueryOptions[]
|
||||
}
|
||||
|
||||
const MergeFaceGroupsModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
sourceFaceGroup,
|
||||
refetchQueries,
|
||||
}: MergeFaceGroupsModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] =
|
||||
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState<
|
||||
myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
>(null)
|
||||
|
||||
const history = useHistory()
|
||||
const { data } = useQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
|
||||
|
@ -50,11 +56,7 @@ const MergeFaceGroupsModal = ({
|
|||
combineFaces,
|
||||
combineFacesVariables
|
||||
>(COMBINE_FACES_MUTATION, {
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
refetchQueries: refetchQueries,
|
||||
})
|
||||
|
||||
if (open == false) return null
|
||||
|
|
|
@ -40,20 +40,26 @@ type MoveImageFacesModalProps = {
|
|||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
faceGroup: singleFaceGroup_faceGroup
|
||||
preselectedImageFaces?: (
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
)[]
|
||||
}
|
||||
|
||||
const MoveImageFacesModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
faceGroup,
|
||||
preselectedImageFaces,
|
||||
}: MoveImageFacesModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedImageFaces, setSelectedImageFaces] = useState<
|
||||
(singleFaceGroup_faceGroup_imageFaces | myFaces_myFaceGroups_imageFaces)[]
|
||||
>([])
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] =
|
||||
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState<
|
||||
myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
>(null)
|
||||
const [imagesSelected, setImagesSelected] = useState(false)
|
||||
const history = useHistory()
|
||||
|
||||
|
@ -68,8 +74,16 @@ const MoveImageFacesModal = ({
|
|||
],
|
||||
})
|
||||
|
||||
const [loadFaceGroups, { data: faceGroupsData }] =
|
||||
useLazyQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
|
||||
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery<
|
||||
myFaces,
|
||||
myFacesVariables
|
||||
>(MY_FACES_QUERY)
|
||||
|
||||
useEffect(() => {
|
||||
if (isNil(preselectedImageFaces)) return
|
||||
setSelectedImageFaces(preselectedImageFaces)
|
||||
setImagesSelected(true)
|
||||
}, [preselectedImageFaces])
|
||||
|
||||
useEffect(() => {
|
||||
if (imagesSelected) {
|
||||
|
|
|
@ -52,14 +52,6 @@ const MapClusterMarker = ({
|
|||
const thumbnail = JSON.parse(marker.thumbnail)
|
||||
|
||||
const presentMedia = () => {
|
||||
// presentMarkerClicked({
|
||||
// dispatchMedia: dispatchMarkerMedia,
|
||||
// mediaState: markerMediaState,
|
||||
// marker: {
|
||||
// cluster: !!marker.cluster,
|
||||
// id: marker.cluster ? marker.cluster_id : marker.media_id,
|
||||
// },
|
||||
// })
|
||||
dispatchMarkerMedia({
|
||||
type: 'replacePresentMarker',
|
||||
marker: {
|
||||
|
@ -67,10 +59,6 @@ const MapClusterMarker = ({
|
|||
id: marker.cluster ? marker.cluster_id : marker.media_id,
|
||||
},
|
||||
})
|
||||
// setPresentMarker({
|
||||
// cluster: !!marker.cluster,
|
||||
// id: marker.cluster ? marker.cluster_id : marker.media_id,
|
||||
// })
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -80,6 +80,9 @@ type MapPresetMarkerProps = {
|
|||
dispatchMarkerMedia: React.Dispatch<PlacesAction>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen present-view that works with PlacesState
|
||||
*/
|
||||
const MapPresentMarker = ({
|
||||
map,
|
||||
markerMediaState,
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
import { gql, useQuery } from '@apollo/client'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import React, { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import React, { useReducer } from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import { makeUpdateMarkers } from './mapboxHelperFunctions'
|
||||
import MapPresentMarker from './MapPresentMarker'
|
||||
import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions'
|
||||
import useMapboxMap from '../../components/mapbox/MapboxMap'
|
||||
import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer'
|
||||
import { placesReducer } from './placesReducer'
|
||||
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import MapPresentMarker from './MapPresentMarker'
|
||||
import { PlacesAction, placesReducer } from './placesReducer'
|
||||
import { mediaGeoJson } from './__generated__/mediaGeoJson'
|
||||
|
||||
const MapWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100vh - 120px);
|
||||
`
|
||||
|
||||
const MapContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const MAPBOX_DATA_QUERY = gql`
|
||||
query placePageMapboxToken {
|
||||
mapboxToken
|
||||
query mediaGeoJson {
|
||||
myMediaGeoJson
|
||||
}
|
||||
`
|
||||
|
@ -37,10 +31,9 @@ export type PresentMarker = {
|
|||
const MapPage = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl | null>()
|
||||
const mapContainer = useRef<HTMLDivElement | null>(null)
|
||||
const map = useRef<mapboxgl.Map | null>(null)
|
||||
// const [presentMarker, setPresentMarker] = useState<PresentMarker | null>(null)
|
||||
const { data: mapboxData } = useQuery<mediaGeoJson>(MAPBOX_DATA_QUERY, {
|
||||
fetchPolicy: 'cache-first',
|
||||
})
|
||||
|
||||
const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, {
|
||||
presenting: false,
|
||||
|
@ -48,76 +41,21 @@ const MapPage = () => {
|
|||
media: [],
|
||||
})
|
||||
|
||||
const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY)
|
||||
const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({
|
||||
configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
async function loadMapboxLibrary() {
|
||||
const mapbox = (await import('mapbox-gl')).default
|
||||
|
||||
setMapboxLibrary(mapbox)
|
||||
}
|
||||
loadMapboxLibrary()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
mapboxLibrary == null ||
|
||||
mapContainer.current == null ||
|
||||
mapboxData == null ||
|
||||
map.current != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
mapboxLibrary.accessToken = mapboxData.mapboxToken
|
||||
|
||||
map.current = new mapboxLibrary.Map({
|
||||
container: mapContainer.current,
|
||||
style: 'mapbox://styles/mapbox/streets-v11',
|
||||
zoom: 1,
|
||||
})
|
||||
|
||||
// Add map navigation control
|
||||
map.current.addControl(new mapboxLibrary.NavigationControl())
|
||||
|
||||
map.current.on('load', () => {
|
||||
if (map.current == null) {
|
||||
console.error('ERROR: map is null')
|
||||
return
|
||||
}
|
||||
|
||||
map.current.addSource('media', {
|
||||
type: 'geojson',
|
||||
data: mapboxData.myMediaGeoJson,
|
||||
cluster: true,
|
||||
clusterRadius: 50,
|
||||
clusterProperties: {
|
||||
thumbnail: ['coalesce', ['get', 'thumbnail'], false],
|
||||
},
|
||||
urlPresentModeSetupHook({
|
||||
dispatchMedia: dispatchMarkerMedia,
|
||||
openPresentMode: event => {
|
||||
dispatchMarkerMedia({
|
||||
type: 'openPresentMode',
|
||||
activeIndex: event.state.activeIndex,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Add dummy layer for features to be queryable
|
||||
map.current.addLayer({
|
||||
id: 'media-points',
|
||||
type: 'circle',
|
||||
source: 'media',
|
||||
filter: ['!', true],
|
||||
})
|
||||
|
||||
const updateMarkers = makeUpdateMarkers({
|
||||
map: map.current,
|
||||
mapboxLibrary,
|
||||
dispatchMarkerMedia,
|
||||
})
|
||||
|
||||
map.current.on('move', updateMarkers)
|
||||
map.current.on('moveend', updateMarkers)
|
||||
map.current.on('sourcedata', updateMarkers)
|
||||
updateMarkers()
|
||||
})
|
||||
}, [mapContainer, mapboxLibrary, mapboxData])
|
||||
|
||||
if (mapboxData && mapboxData.mapboxToken == null) {
|
||||
if (mapboxData && mapboxToken == null) {
|
||||
return (
|
||||
<Layout title={t('places_page.title', 'Places')}>
|
||||
<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 (
|
||||
<Layout title="Places">
|
||||
<Helmet>
|
||||
{/* <link rel="stylesheet" href="/mapbox-gl.css" /> */}
|
||||
{/* <style type="text/css">{mapboxStyles}</style> */}
|
||||
</Helmet>
|
||||
<MapWrapper>
|
||||
<MapContainer ref={mapContainer}></MapContainer>
|
||||
</MapWrapper>
|
||||
<MapWrapper>{mapContainer}</MapWrapper>
|
||||
<MapPresentMarker
|
||||
map={map.current}
|
||||
map={mapboxMap}
|
||||
markerMediaState={markerMediaState}
|
||||
dispatchMarkerMedia={dispatchMarkerMedia}
|
||||
// presentMarker={presentMarker}
|
||||
// setPresentMarker={setPresentMarker}
|
||||
/>
|
||||
</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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -66,6 +66,7 @@ export const SHARE_ALBUM_QUERY = gql`
|
|||
url
|
||||
}
|
||||
exif {
|
||||
id
|
||||
camera
|
||||
maker
|
||||
lens
|
||||
|
@ -76,6 +77,10 @@ export const SHARE_ALBUM_QUERY = gql`
|
|||
focalLength
|
||||
flash
|
||||
exposureProgram
|
||||
coordinates {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
ProtectedVideo,
|
||||
} from '../../components/photoGallery/ProtectedMedia'
|
||||
import { SidebarContext } from '../../components/sidebar/Sidebar'
|
||||
import MediaSidebar from '../../components/sidebar/MediaSidebar'
|
||||
import MediaSidebar from '../../components/sidebar/MediaSidebar/MediaSidebar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SharePageToken_shareToken_media } from './__generated__/SharePageToken'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
|
|
|
@ -59,6 +59,10 @@ export const SHARE_TOKEN_QUERY = gql`
|
|||
focalLength
|
||||
flash
|
||||
exposureProgram
|
||||
coordinates {
|
||||
longitude
|
||||
latitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,172 +3,188 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from "./../../../__generated__/globalTypes";
|
||||
import { MediaType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: SharePageToken
|
||||
// ====================================================
|
||||
|
||||
export interface SharePageToken_shareToken_album {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_downloads_mediaUrl {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
/**
|
||||
* The file size of the resource in bytes
|
||||
*/
|
||||
fileSize: number;
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_downloads {
|
||||
__typename: "MediaDownload";
|
||||
title: string;
|
||||
mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl;
|
||||
__typename: 'MediaDownload'
|
||||
title: string
|
||||
mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_highRes {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_videoWeb {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_exif_coordinates {
|
||||
__typename: 'Coordinates'
|
||||
/**
|
||||
* GPS longitude in degrees
|
||||
*/
|
||||
longitude: number
|
||||
/**
|
||||
* GPS latitude in degrees
|
||||
*/
|
||||
latitude: number
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media_exif {
|
||||
__typename: "MediaEXIF";
|
||||
id: string;
|
||||
__typename: 'MediaEXIF'
|
||||
id: string
|
||||
/**
|
||||
* The model name of the camera
|
||||
*/
|
||||
camera: string | null;
|
||||
camera: string | null
|
||||
/**
|
||||
* The maker of the camera
|
||||
*/
|
||||
maker: string | null;
|
||||
maker: string | null
|
||||
/**
|
||||
* The name of the lens
|
||||
*/
|
||||
lens: string | null;
|
||||
dateShot: any | null;
|
||||
lens: string | null
|
||||
dateShot: any | null
|
||||
/**
|
||||
* The exposure time of the image
|
||||
*/
|
||||
exposure: number | null;
|
||||
exposure: number | null
|
||||
/**
|
||||
* The aperature stops of the image
|
||||
*/
|
||||
aperture: number | null;
|
||||
aperture: number | null
|
||||
/**
|
||||
* The ISO setting of the image
|
||||
*/
|
||||
iso: number | null;
|
||||
iso: number | null
|
||||
/**
|
||||
* The focal length of the lens, when the image was taken
|
||||
*/
|
||||
focalLength: number | null;
|
||||
focalLength: number | null
|
||||
/**
|
||||
* A formatted description of the flash settings, when the image was taken
|
||||
*/
|
||||
flash: number | null;
|
||||
flash: number | null
|
||||
/**
|
||||
* An index describing the mode for adjusting the exposure of the image
|
||||
*/
|
||||
exposureProgram: number | null;
|
||||
exposureProgram: number | null
|
||||
/**
|
||||
* GPS coordinates of where the image was taken
|
||||
*/
|
||||
coordinates: SharePageToken_shareToken_media_exif_coordinates | null
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken_media {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
title: string;
|
||||
type: MediaType;
|
||||
__typename: 'Media'
|
||||
id: string
|
||||
title: string
|
||||
type: MediaType
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: SharePageToken_shareToken_media_thumbnail | null;
|
||||
downloads: SharePageToken_shareToken_media_downloads[];
|
||||
thumbnail: SharePageToken_shareToken_media_thumbnail | null
|
||||
downloads: SharePageToken_shareToken_media_downloads[]
|
||||
/**
|
||||
* URL to display the photo in full resolution, will be null for videos
|
||||
*/
|
||||
highRes: SharePageToken_shareToken_media_highRes | null;
|
||||
highRes: SharePageToken_shareToken_media_highRes | null
|
||||
/**
|
||||
* URL to get the video in a web format that can be played in the browser, will be null for photos
|
||||
*/
|
||||
videoWeb: SharePageToken_shareToken_media_videoWeb | null;
|
||||
exif: SharePageToken_shareToken_media_exif | null;
|
||||
videoWeb: SharePageToken_shareToken_media_videoWeb | null
|
||||
exif: SharePageToken_shareToken_media_exif | null
|
||||
}
|
||||
|
||||
export interface SharePageToken_shareToken {
|
||||
__typename: "ShareToken";
|
||||
token: string;
|
||||
__typename: 'ShareToken'
|
||||
token: string
|
||||
/**
|
||||
* The album this token shares
|
||||
*/
|
||||
album: SharePageToken_shareToken_album | null;
|
||||
album: SharePageToken_shareToken_album | null
|
||||
/**
|
||||
* The media this token shares
|
||||
*/
|
||||
media: SharePageToken_shareToken_media | null;
|
||||
media: SharePageToken_shareToken_media | null
|
||||
}
|
||||
|
||||
export interface SharePageToken {
|
||||
shareToken: SharePageToken_shareToken;
|
||||
shareToken: SharePageToken_shareToken
|
||||
}
|
||||
|
||||
export interface SharePageTokenVariables {
|
||||
token: string;
|
||||
password?: string | null;
|
||||
token: string
|
||||
password?: string | null
|
||||
}
|
||||
|
|
|
@ -8,15 +8,15 @@
|
|||
// ====================================================
|
||||
|
||||
export interface albumPathQuery_album_path {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface albumPathQuery_album {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
path: albumPathQuery_album_path[];
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
path: albumPathQuery_album_path[]
|
||||
}
|
||||
|
||||
export interface albumPathQuery {
|
||||
|
@ -24,9 +24,9 @@ export interface albumPathQuery {
|
|||
* Get album by id, user must own the album or be admin
|
||||
* If valid tokenCredentials are provided, the album may be retrived without further authentication
|
||||
*/
|
||||
album: albumPathQuery_album;
|
||||
album: albumPathQuery_album
|
||||
}
|
||||
|
||||
export interface albumPathQueryVariables {
|
||||
id: string;
|
||||
id: string
|
||||
}
|
||||
|
|
|
@ -10,8 +10,10 @@ import useDelay from '../../hooks/useDelay'
|
|||
|
||||
import { ReactComponent as GearIcon } from './icons/gear.svg'
|
||||
|
||||
const BreadcrumbList = styled.ol`
|
||||
& li::after {
|
||||
export const BreadcrumbList = styled.ol<{ hideLastArrow?: boolean }>`
|
||||
&
|
||||
${({ hideLastArrow }) =>
|
||||
hideLastArrow ? 'li:not(:last-child)::after' : 'li::after'} {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='5px' height='6px' viewBox='0 0 5 6'%3E%3Cpolyline fill='none' stroke='%23979797' points='0.74 0.167710644 3.57228936 3 0.74 5.83228936' /%3E%3C/svg%3E");
|
||||
width: 5px;
|
||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { MediaSidebarMedia } from '../sidebar/MediaSidebar'
|
||||
import { sidebarPhoto_media_faces } from '../sidebar/__generated__/sidebarPhoto'
|
||||
import { MediaSidebarMedia } from '../sidebar/MediaSidebar/MediaSidebar'
|
||||
import { sidebarMediaQuery_media_faces } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery'
|
||||
|
||||
interface FaceBoxStyleProps {
|
||||
$minY: number
|
||||
|
@ -23,7 +23,7 @@ const FaceBoxStyle = styled(Link)`
|
|||
`
|
||||
|
||||
type FaceBoxProps = {
|
||||
face: sidebarPhoto_media_faces
|
||||
face: sidebarMediaQuery_media_faces
|
||||
}
|
||||
|
||||
const FaceBox = ({ face /*media*/ }: FaceBoxProps) => {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -3,7 +3,6 @@ import styled from 'styled-components'
|
|||
import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail'
|
||||
import PresentView from './presentView/PresentView'
|
||||
import { PresentMediaProps_Media } from './presentView/PresentMedia'
|
||||
import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto'
|
||||
import {
|
||||
openPresentModeAction,
|
||||
PhotoGalleryAction,
|
||||
|
@ -13,8 +12,9 @@ import {
|
|||
toggleFavoriteAction,
|
||||
useMarkFavoriteMutation,
|
||||
} from './photoGalleryMutations'
|
||||
import MediaSidebar from '../sidebar/MediaSidebar'
|
||||
import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar'
|
||||
import { SidebarContext } from '../sidebar/Sidebar'
|
||||
import { sidebarMediaQuery_media_thumbnail } from '../sidebar/MediaSidebar/__generated__/sidebarMediaQuery'
|
||||
|
||||
const Gallery = styled.div`
|
||||
display: flex;
|
||||
|
@ -36,7 +36,7 @@ export const PhotoFiller = styled.div`
|
|||
`
|
||||
|
||||
export interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
|
||||
thumbnail: sidebarPhoto_media_thumbnail | null
|
||||
thumbnail: sidebarMediaQuery_media_thumbnail | null
|
||||
favorite?: boolean
|
||||
}
|
||||
|
||||
|
|
|
@ -7,11 +7,15 @@ import * as authentication from '../../helpers/authentication'
|
|||
|
||||
jest.mock('../../helpers/authentication.ts')
|
||||
|
||||
const authToken = authentication.authToken as jest.Mock<
|
||||
ReturnType<typeof authentication.authToken>
|
||||
>
|
||||
|
||||
describe('AuthorizedRoute component', () => {
|
||||
const AuthorizedComponent = () => <div>authorized content</div>
|
||||
|
||||
test('not logged in', async () => {
|
||||
authentication.authToken.mockImplementation(() => null)
|
||||
authToken.mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
|
@ -24,7 +28,7 @@ describe('AuthorizedRoute component', () => {
|
|||
})
|
||||
|
||||
test('logged in', async () => {
|
||||
authentication.authToken.mockImplementation(() => 'token-here')
|
||||
authToken.mockImplementation(() => 'token-here')
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
|
@ -12,6 +12,7 @@ import {
|
|||
resetAlbumCover,
|
||||
resetAlbumCoverVariables,
|
||||
} from './__generated__/resetAlbumCover'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
|
||||
const RESET_ALBUM_COVER_MUTATION = gql`
|
||||
mutation resetAlbumCover($albumID: ID!) {
|
||||
|
@ -62,6 +63,11 @@ export const SidebarPhotoCover = ({ cover_id }: SidebarPhotoCoverProps) => {
|
|||
setButtonDisabled(false)
|
||||
}, [cover_id])
|
||||
|
||||
// hide when not authenticated
|
||||
if (!authToken()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection>
|
||||
<SidebarSectionTitle>
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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
|
|
@ -1,13 +1,15 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MetadataInfo } from './MediaSidebar'
|
||||
import ExifDetails from './MediaSidebarExif'
|
||||
import { MediaSidebarMedia } from './MediaSidebar'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
|
||||
describe('MetadataInfo', () => {
|
||||
describe('ExifDetails', () => {
|
||||
test('without EXIF information', async () => {
|
||||
const media = {
|
||||
const media: MediaSidebarMedia = {
|
||||
id: '1730',
|
||||
title: 'media_name.jpg',
|
||||
type: 'Photo',
|
||||
type: MediaType.Photo,
|
||||
exif: {
|
||||
id: '0',
|
||||
camera: null,
|
||||
|
@ -20,12 +22,13 @@ describe('MetadataInfo', () => {
|
|||
focalLength: null,
|
||||
flash: null,
|
||||
exposureProgram: null,
|
||||
coordinates: null,
|
||||
__typename: 'MediaEXIF',
|
||||
},
|
||||
__typename: 'Media',
|
||||
}
|
||||
|
||||
render(<MetadataInfo media={media} />)
|
||||
render(<ExifDetails media={media} />)
|
||||
|
||||
expect(screen.queryByText('Camera')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Maker')).not.toBeInTheDocument()
|
||||
|
@ -37,13 +40,14 @@ describe('MetadataInfo', () => {
|
|||
expect(screen.queryByText('ISO')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Focal length')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Flash')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Coordinates')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('with EXIF information', async () => {
|
||||
const media = {
|
||||
const media: MediaSidebarMedia = {
|
||||
id: '1730',
|
||||
title: 'media_name.jpg',
|
||||
type: 'Photo',
|
||||
type: MediaType.Photo,
|
||||
exif: {
|
||||
id: '1666',
|
||||
camera: 'Canon EOS R',
|
||||
|
@ -56,12 +60,17 @@ describe('MetadataInfo', () => {
|
|||
focalLength: 24,
|
||||
flash: 9,
|
||||
exposureProgram: 3,
|
||||
coordinates: {
|
||||
__typename: 'Coordinates',
|
||||
latitude: 41.40338,
|
||||
longitude: 2.17403,
|
||||
},
|
||||
__typename: 'MediaEXIF',
|
||||
},
|
||||
__typename: 'Media',
|
||||
}
|
||||
|
||||
render(<MetadataInfo media={media} />)
|
||||
render(<ExifDetails media={media} />)
|
||||
|
||||
expect(screen.getByText('Camera')).toBeInTheDocument()
|
||||
expect(screen.getByText('Canon EOS R')).toBeInTheDocument()
|
||||
|
@ -94,5 +103,8 @@ describe('MetadataInfo', () => {
|
|||
|
||||
expect(screen.getByText('Flash')).toBeInTheDocument()
|
||||
expect(screen.getByText('On, Fired')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('Coordinates')).toBeInTheDocument()
|
||||
expect(screen.getByText('41.40338, 2.17403')).toBeInTheDocument()
|
||||
})
|
||||
})
|
|
@ -1,143 +1,20 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { useLazyQuery, gql } from '@apollo/client'
|
||||
import styled from 'styled-components'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import {
|
||||
ProtectedImage,
|
||||
ProtectedVideo,
|
||||
ProtectedVideoProps_Media,
|
||||
} from '../photoGallery/ProtectedMedia'
|
||||
import { SidebarPhotoShare } from './Sharing'
|
||||
import SidebarMediaDownload from './SidebarDownloadMedia'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay'
|
||||
import { isNil } from '../../helpers/utils'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { TranslationFn } from '../../localization'
|
||||
import {
|
||||
sidebarPhoto,
|
||||
sidebarPhotoVariables,
|
||||
sidebarPhoto_media_exif,
|
||||
sidebarPhoto_media_faces,
|
||||
sidebarPhoto_media_thumbnail,
|
||||
sidebarPhoto_media_videoMetadata,
|
||||
} from './__generated__/sidebarPhoto'
|
||||
|
||||
import { sidebarDownloadQuery_media_downloads } from './__generated__/sidebarDownloadQuery'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import { SidebarPhotoCover } from './AlbumCovers'
|
||||
|
||||
const SIDEBAR_MEDIA_QUERY = gql`
|
||||
query sidebarPhoto($id: ID!) {
|
||||
media(id: $id) {
|
||||
id
|
||||
title
|
||||
type
|
||||
highRes {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoWeb {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoMetadata {
|
||||
id
|
||||
width
|
||||
height
|
||||
duration
|
||||
codec
|
||||
framerate
|
||||
bitrate
|
||||
colorProfile
|
||||
audio
|
||||
}
|
||||
exif {
|
||||
id
|
||||
camera
|
||||
maker
|
||||
lens
|
||||
dateShot
|
||||
exposure
|
||||
aperture
|
||||
iso
|
||||
focalLength
|
||||
flash
|
||||
exposureProgram
|
||||
}
|
||||
faces {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
faceGroup {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewImage = styled(ProtectedImage)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
object-fit: contain;
|
||||
`
|
||||
|
||||
const PreviewVideo = styled(ProtectedVideo)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`
|
||||
|
||||
interface PreviewMediaPropsMedia extends ProtectedVideoProps_Media {
|
||||
type: MediaType
|
||||
}
|
||||
|
||||
type PreviewMediaProps = {
|
||||
media: PreviewMediaPropsMedia
|
||||
previewImage?: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
const PreviewMedia = ({ media, previewImage }: PreviewMediaProps) => {
|
||||
if (media.type === MediaType.Photo) {
|
||||
return <PreviewImage src={previewImage?.url} />
|
||||
}
|
||||
|
||||
if (media.type === MediaType.Video) {
|
||||
return <PreviewVideo media={media} />
|
||||
}
|
||||
|
||||
return <div>ERROR: Unknown media type: {media.type}</div>
|
||||
}
|
||||
import styled from 'styled-components'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import { TranslationFn } from '../../../localization'
|
||||
import SidebarItem from '../SidebarItem'
|
||||
import { MediaSidebarMedia } from './MediaSidebar'
|
||||
|
||||
const MetadataInfoContainer = styled.div`
|
||||
margin-bottom: 1.5rem;
|
||||
`
|
||||
|
||||
type MediaInfoProps = {
|
||||
type ExifDetailsProps = {
|
||||
media?: MediaSidebarMedia
|
||||
}
|
||||
|
||||
export const MetadataInfo = ({ media }: MediaInfoProps) => {
|
||||
const ExifDetails = ({ media }: ExifDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
let exifItems: JSX.Element[] = []
|
||||
|
||||
|
@ -170,6 +47,13 @@ export const MetadataInfo = ({ media }: MediaInfoProps) => {
|
|||
exif.exposure = `1/${Math.round(1 / exif.exposure)}`
|
||||
}
|
||||
|
||||
const coordinates = media.exif.coordinates
|
||||
if (!isNil(coordinates)) {
|
||||
exif.coordinates = `${
|
||||
Math.round(coordinates.latitude * 1000000) / 1000000
|
||||
}, ${Math.round(coordinates.longitude * 1000000) / 1000000}`
|
||||
}
|
||||
|
||||
const exposurePrograms = exposureProgramsLookup(t)
|
||||
|
||||
if (
|
||||
|
@ -246,6 +130,7 @@ const exifNameLookup = (t: TranslationFn): { [key: string]: string } => ({
|
|||
iso: t('sidebar.media.exif.name.iso', 'ISO'),
|
||||
focalLength: t('sidebar.media.exif.name.focal_length', 'Focal length'),
|
||||
flash: t('sidebar.media.exif.name.flash', 'Flash'),
|
||||
coordinates: t('sidebar.media.exif.name.coordinates', 'Coordinates'),
|
||||
})
|
||||
|
||||
// From https://exiftool.org/TagNames/EXIF.html
|
||||
|
@ -331,106 +216,4 @@ const flashLookup = (t: TranslationFn): { [key: number]: string } => {
|
|||
}
|
||||
}
|
||||
|
||||
type SidebarContentProps = {
|
||||
media: MediaSidebarMedia
|
||||
hidePreview?: boolean
|
||||
}
|
||||
|
||||
const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
|
||||
let previewImage = null
|
||||
if (media.highRes) previewImage = media.highRes
|
||||
else if (media.thumbnail) previewImage = media.thumbnail
|
||||
|
||||
const imageAspect =
|
||||
previewImage?.width && previewImage?.height
|
||||
? previewImage.height / previewImage.width
|
||||
: 3 / 2
|
||||
|
||||
return (
|
||||
<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
|
||||
export default ExifDetails
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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 |
|
@ -13,7 +13,6 @@ import {
|
|||
sidebareDeleteShare,
|
||||
sidebareDeleteShareVariables,
|
||||
} from './__generated__/sidebareDeleteShare'
|
||||
import { sidbarGetPhotoShares_media_shares } from './__generated__/sidbarGetPhotoShares'
|
||||
import {
|
||||
sidebarPhotoAddShare,
|
||||
sidebarPhotoAddShareVariables,
|
||||
|
@ -25,6 +24,7 @@ import {
|
|||
import {
|
||||
sidebarGetPhotoShares,
|
||||
sidebarGetPhotoSharesVariables,
|
||||
sidebarGetPhotoShares_media_shares,
|
||||
} from './__generated__/sidebarGetPhotoShares'
|
||||
import {
|
||||
sidebarGetAlbumShares,
|
||||
|
@ -106,18 +106,38 @@ const DELETE_SHARE_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const ArrowPopoverPanel = styled.div.attrs({
|
||||
export const ArrowPopoverPanel = styled.div.attrs({
|
||||
className:
|
||||
'absolute right-6 -top-3 bg-white rounded shadow-md border border-gray-200 w-[260px]',
|
||||
})`
|
||||
'absolute -top-3 bg-white rounded shadow-md border border-gray-200 z-10',
|
||||
})<{ width: number; flipped?: boolean }>`
|
||||
width: ${({ width }) => width}px;
|
||||
|
||||
${({ flipped }) =>
|
||||
flipped
|
||||
? `
|
||||
left: 32px;
|
||||
`
|
||||
: `
|
||||
right: 24px;
|
||||
`}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: -7px;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 14'%3E%3Cpolyline stroke-width='1' stroke='%23E2E2E2' fill='%23FFFFFF' points='1 0 7 7 1 14'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
|
||||
${({ flipped }) =>
|
||||
flipped
|
||||
? `
|
||||
left: -7px;
|
||||
transform: rotate(180deg);
|
||||
`
|
||||
: `
|
||||
right: -7px;
|
||||
`}
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -247,7 +267,7 @@ const MorePopover = ({ id, share, query }: MorePopoverProps) => {
|
|||
</Popover.Button>
|
||||
|
||||
<Popover.Panel>
|
||||
<ArrowPopoverPanel>
|
||||
<ArrowPopoverPanel width={260}>
|
||||
<MorePopoverSectionPassword id={id} share={share} query={query} />
|
||||
<div className="px-4 py-2 border-t border-gray-200 mt-2 mb-2">
|
||||
<Checkbox label="Expiration date" />
|
||||
|
@ -358,7 +378,7 @@ type SidebarShareProps = {
|
|||
id: string
|
||||
isPhoto: boolean
|
||||
loading: boolean
|
||||
shares?: sidbarGetPhotoShares_media_shares[]
|
||||
shares?: sidebarGetPhotoShares_media_shares[]
|
||||
shareItem(item: { variables: { id: string } }): void
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useLazyQuery, gql } from '@apollo/client'
|
|||
import { authToken } from '../../helpers/authentication'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TranslationFn } from '../../localization'
|
||||
import { MediaSidebarMedia } from './MediaSidebar'
|
||||
import { MediaSidebarMedia } from './MediaSidebar/MediaSidebar'
|
||||
import {
|
||||
sidebarDownloadQuery,
|
||||
sidebarDownloadQueryVariables,
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -53,7 +53,9 @@ const DateSelector = ({ filterDate, setFilterDate }: DateSelectorProps) => {
|
|||
|
||||
const yearItems = years.map<DropdownItem>(x => ({
|
||||
value: `${x}`,
|
||||
label: `${x} and earlier`,
|
||||
label: t('timeline_filter.date.dropdown_year', '{{year}} and earlier', {
|
||||
year: x,
|
||||
}),
|
||||
}))
|
||||
items = [...items, ...yearItems]
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
toggleFavoriteAction,
|
||||
useMarkFavoriteMutation,
|
||||
} from '../photoGallery/photoGalleryMutations'
|
||||
import MediaSidebar from '../sidebar/MediaSidebar'
|
||||
import MediaSidebar from '../sidebar/MediaSidebar/MediaSidebar'
|
||||
import { SidebarContext } from '../sidebar/Sidebar'
|
||||
import {
|
||||
getActiveTimelineImage,
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "Ændre navn",
|
||||
"detach_face": "Løsriv billeder",
|
||||
"merge_face": "Sammenflet personer",
|
||||
"detach_images": "Løsriv billeder",
|
||||
"merge_people": "Sammenflet personer",
|
||||
"move_faces": "Flyt ansigter"
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Download"
|
||||
},
|
||||
"location": {
|
||||
"title": "Lokation"
|
||||
},
|
||||
"media": {
|
||||
"album_path": "Album sti",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Actionprogram",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Blænde",
|
||||
"camera": "Kamera",
|
||||
"coordinates": "Koordinater",
|
||||
"date_shot": "Dato",
|
||||
"exposure": "Lukketid",
|
||||
"exposure_program": "Lukketid program",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "Løsriv billede",
|
||||
"merge_face": "Sammenflet person",
|
||||
"move_face": "Flyt person"
|
||||
},
|
||||
"confirm_image_detach": "Er du sikker på at du vil løsrive dette billede?",
|
||||
"title": "Personer"
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Tilføj deling",
|
||||
"copy_link": "Kopier link",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "Fra i dag",
|
||||
"dropdown_year": "{{year}} og tidligere",
|
||||
"label": "Dato"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -42,6 +42,15 @@
|
|||
"merge_face": null,
|
||||
"detach_face": null,
|
||||
"move_faces": null
|
||||
},
|
||||
"modal": {
|
||||
"merge_people_groups": {
|
||||
"description": "Alle billeder fra denne gruppe vil blive flettet sammen med den valgte gruppe",
|
||||
"destination_table": {
|
||||
"title": "Vælg destinationsgruppe"
|
||||
},
|
||||
"title": "Vælg gruppe at flette med"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -71,6 +80,9 @@
|
|||
"mega_byte_plural": null,
|
||||
"tera_byte_plural": null
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"album": "Album"
|
||||
}
|
||||
},
|
||||
"timeline_filter": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Download"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Action (kurze Verschlusszeit)",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Blende",
|
||||
"camera": "Kamera",
|
||||
"coordinates": "",
|
||||
"date_shot": "Aufnahmedatum",
|
||||
"exposure": "Belichtung",
|
||||
"exposure_program": "Programm",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Freigabe hinzufügen",
|
||||
"copy_link": "Link kopieren",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -61,6 +61,13 @@
|
|||
"title": null
|
||||
},
|
||||
"title": null
|
||||
},
|
||||
"merge_people_groups": {
|
||||
"description": "",
|
||||
"destination_table": {
|
||||
"title": ""
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
@ -115,6 +122,9 @@
|
|||
"mega_byte_plural": null,
|
||||
"tera_byte_plural": null
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"album_filter": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "Change label",
|
||||
"detach_face": "Detach face",
|
||||
"merge_face": "Merge face",
|
||||
"detach_images": "Detach face",
|
||||
"merge_people": "Merge face",
|
||||
"move_faces": "Move faces"
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Download"
|
||||
},
|
||||
"location": {
|
||||
"title": "Location"
|
||||
},
|
||||
"media": {
|
||||
"album_path": "Album path",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Action program",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Aperture",
|
||||
"camera": "Camera",
|
||||
"coordinates": "Coordinates",
|
||||
"date_shot": "Date shot",
|
||||
"exposure": "Exposure",
|
||||
"exposure_program": "Program",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "Detach image",
|
||||
"merge_face": "Merge face",
|
||||
"move_face": "Move face"
|
||||
},
|
||||
"confirm_image_detach": "Are you sure you want to detach this image?",
|
||||
"title": "People"
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Add shares",
|
||||
"copy_link": "Copy Link",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "From today",
|
||||
"dropdown_year": "{{year}} and earlier",
|
||||
"label": "Date"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -36,6 +36,15 @@
|
|||
"select_image_faces": {
|
||||
"search_images_placeholder": "Search images..."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"merge_people_groups": {
|
||||
"description": "All images within this face group will be merged into the selected face group.",
|
||||
"destination_table": {
|
||||
"title": "Select the destination face"
|
||||
},
|
||||
"title": "Merge Face Groups"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -54,6 +63,9 @@
|
|||
},
|
||||
"sharing": {
|
||||
"table_header": "Public shares"
|
||||
},
|
||||
"media": {
|
||||
"album": "Album"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Descargar"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Programa de acción",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Abertura",
|
||||
"camera": "Cámara",
|
||||
"coordinates": "",
|
||||
"date_shot": "Fecha de captura",
|
||||
"exposure": "Exposición",
|
||||
"exposure_program": "Programa",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Añadir compartido",
|
||||
"copy_link": "Copiar enlace",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -61,6 +61,13 @@
|
|||
"title": null
|
||||
},
|
||||
"title": null
|
||||
},
|
||||
"merge_people_groups": {
|
||||
"description": "",
|
||||
"destination_table": {
|
||||
"title": ""
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
@ -120,6 +127,9 @@
|
|||
"mega_byte_plural": null,
|
||||
"tera_byte_plural": null
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "Changer le label",
|
||||
"detach_face": "Détacher le visage",
|
||||
"merge_face": "Fusionner le visage",
|
||||
"detach_images": "Détacher le visage",
|
||||
"merge_people": "Fusionner le visage",
|
||||
"move_faces": "Déplacer les visages"
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Télécharger"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Programme d'action",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Ouverture",
|
||||
"camera": "Modèle",
|
||||
"coordinates": "",
|
||||
"date_shot": "Prise de vue",
|
||||
"exposure": "Vitesse",
|
||||
"exposure_program": "Programme",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Ajouter un partage",
|
||||
"copy_link": "Copier le lien",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "Depuis aujourd'hui",
|
||||
"dropdown_year": "",
|
||||
"label": "Date"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -60,6 +60,13 @@
|
|||
"title": null
|
||||
},
|
||||
"title": null
|
||||
},
|
||||
"merge_people_groups": {
|
||||
"description": "Toutes les images de ce groupe de visages seront fusionnées dans le groupe de visages sélectionné.",
|
||||
"destination_table": {
|
||||
"title": "Sélectionner le visage de destination"
|
||||
},
|
||||
"title": "Fusionner des groupes de visages"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
@ -95,6 +102,9 @@
|
|||
},
|
||||
"sharing": {
|
||||
"table_header": "Partages publics"
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Download"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Programma sport",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Apertura",
|
||||
"camera": "Fotocamera",
|
||||
"coordinates": "",
|
||||
"date_shot": "Data di scatto",
|
||||
"exposure": "Esposizione",
|
||||
"exposure_program": "Programma",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Aggiungi condivisione",
|
||||
"copy_link": "Copia il link",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -49,6 +49,15 @@
|
|||
},
|
||||
"tableselect_image_faces": {
|
||||
"search_images_placeholder": null
|
||||
},
|
||||
"modal": {
|
||||
"merge_people_groups": {
|
||||
"description": "Tutte le immagini di questo volto saranno unite alle immagini del volto selezionato",
|
||||
"destination_table": {
|
||||
"title": "Seleziona il volto di destinazione"
|
||||
},
|
||||
"title": "Unisci immagini volto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -82,6 +91,9 @@
|
|||
"mega_byte_plural": null,
|
||||
"tera_byte_plural": null
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"album_filter": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -264,7 +264,11 @@
|
|||
},
|
||||
"title": "Pobierz"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Program działania",
|
||||
|
@ -293,6 +297,7 @@
|
|||
"name": {
|
||||
"aperture": "Przysłona",
|
||||
"camera": "Aparat ",
|
||||
"coordinates": "",
|
||||
"date_shot": "Data wykonania",
|
||||
"exposure": "Ekspozycja",
|
||||
"exposure_program": "Program",
|
||||
|
@ -304,6 +309,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Dodaj udział",
|
||||
"copy_link": "Skopiuj link",
|
||||
|
@ -324,6 +338,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -61,6 +61,13 @@
|
|||
"title": null
|
||||
},
|
||||
"title": null
|
||||
},
|
||||
"merge_people_groups": {
|
||||
"description": "",
|
||||
"destination_table": {
|
||||
"title": ""
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
@ -137,6 +144,9 @@
|
|||
"table_header": "Publiczne udostępnienia",
|
||||
"delete": null,
|
||||
"more": null
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
|
|
|
@ -61,17 +61,17 @@
|
|||
"description": "Galeria de fotos simples e fácil de usar para servidores pessoais"
|
||||
},
|
||||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
"label_placeholder": "Etiqueta",
|
||||
"unlabeled": "Sem etiqueta",
|
||||
"unlabeled_person": "Pessoa sem etiqueta"
|
||||
},
|
||||
"action_label": {
|
||||
"change_label": null,
|
||||
"merge_face": null,
|
||||
"detach_face": null,
|
||||
"move_faces": null
|
||||
},
|
||||
"modal": {
|
||||
"action": {
|
||||
"merge": "Juntar"
|
||||
|
@ -215,6 +215,27 @@
|
|||
},
|
||||
"sidebar": {
|
||||
"album": {
|
||||
"album_cover": "",
|
||||
"download": {
|
||||
"high-resolutions": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"originals": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"thumbnails": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"web-videos": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"reset_cover": "",
|
||||
"set_cover": "",
|
||||
"title_placeholder": "Título do Álbum"
|
||||
},
|
||||
"download": {
|
||||
|
@ -238,7 +259,11 @@
|
|||
},
|
||||
"title": "Descarregar"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Programa de ação",
|
||||
|
@ -267,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Abertura",
|
||||
"camera": "Câmera",
|
||||
"coordinates": "",
|
||||
"date_shot": "Data da foto",
|
||||
"exposure": "Exposição",
|
||||
"exposure_program": "Programa",
|
||||
|
@ -278,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Adicionar partilha",
|
||||
"copy_link": "Copiar link",
|
||||
|
@ -295,6 +330,13 @@
|
|||
"places": "Locais",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"loading_album": "Carregar Álbum",
|
||||
"login": "Login",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -264,7 +264,11 @@
|
|||
},
|
||||
"title": "Скачать"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Действие программа",
|
||||
|
@ -293,6 +297,7 @@
|
|||
"name": {
|
||||
"aperture": "Диафрагма",
|
||||
"camera": "Камера",
|
||||
"coordinates": "",
|
||||
"date_shot": "Дата снимка",
|
||||
"exposure": "Экспозиция",
|
||||
"exposure_program": "Программа",
|
||||
|
@ -304,6 +309,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Поделится",
|
||||
"copy_link": "Скопировать ссылку",
|
||||
|
@ -324,6 +338,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -49,6 +49,15 @@
|
|||
},
|
||||
"tableselect_image_faces": {
|
||||
"search_images_placeholder": null
|
||||
},
|
||||
"modal": {
|
||||
"merge_people_groups": {
|
||||
"description": "Все фотографии в этой группе лиц будут обьеденены с выбранной группой.",
|
||||
"destination_table": {
|
||||
"title": "Выберете конечное лицо"
|
||||
},
|
||||
"title": "Обьединить группы лиц"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
@ -99,6 +108,9 @@
|
|||
"table_header": "Общий доступ",
|
||||
"delete": null,
|
||||
"more": null
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"album_filter": {
|
||||
|
|
|
@ -63,8 +63,8 @@
|
|||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_face": "",
|
||||
"merge_face": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
|
@ -259,7 +259,11 @@
|
|||
},
|
||||
"title": "Ladda ner"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "Actionprogram",
|
||||
|
@ -288,6 +292,7 @@
|
|||
"name": {
|
||||
"aperture": "Bländare",
|
||||
"camera": "Kamera",
|
||||
"coordinates": "",
|
||||
"date_shot": "Datum tagen",
|
||||
"exposure": "Exponering",
|
||||
"exposure_program": "Program",
|
||||
|
@ -299,6 +304,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "Dela",
|
||||
"copy_link": "Kopiera länk",
|
||||
|
@ -319,6 +333,7 @@
|
|||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
|
|
|
@ -61,6 +61,13 @@
|
|||
"title": null
|
||||
},
|
||||
"title": null
|
||||
},
|
||||
"merge_people_groups": {
|
||||
"description": "",
|
||||
"destination_table": {
|
||||
"title": ""
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
@ -123,6 +130,9 @@
|
|||
"mega_byte_plural": null,
|
||||
"tera_byte_plural": null
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"album": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
|
|
|
@ -61,17 +61,17 @@
|
|||
"description": "简单及易用的照片库给个人服务器"
|
||||
},
|
||||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
"label_placeholder": "名称",
|
||||
"unlabeled": "未命名",
|
||||
"unlabeled_person": "未命名人物"
|
||||
},
|
||||
"action_label": {
|
||||
"change_label": null,
|
||||
"merge_face": null,
|
||||
"detach_face": null,
|
||||
"move_faces": null
|
||||
},
|
||||
"modal": {
|
||||
"action": {
|
||||
"merge": "合并"
|
||||
|
@ -215,30 +215,36 @@
|
|||
},
|
||||
"sidebar": {
|
||||
"album": {
|
||||
"album_cover": "",
|
||||
"download": {
|
||||
"high-resolutions": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"originals": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"thumbnails": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"web-videos": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"reset_cover": "",
|
||||
"set_cover": "",
|
||||
"title_placeholder": "相册名称"
|
||||
},
|
||||
"download": {
|
||||
"filesize": {
|
||||
"byte": null,
|
||||
"giga_byte": null,
|
||||
"kilo_byte": null,
|
||||
"mega_byte": null,
|
||||
"tera_byte": null,
|
||||
"byte_0": null,
|
||||
"byte_1": null,
|
||||
"byte_2": null,
|
||||
"giga_byte_0": null,
|
||||
"giga_byte_1": null,
|
||||
"giga_byte_2": null,
|
||||
"kilo_byte_0": null,
|
||||
"kilo_byte_1": null,
|
||||
"kilo_byte_2": null,
|
||||
"mega_byte_0": null,
|
||||
"mega_byte_1": null,
|
||||
"mega_byte_2": null,
|
||||
"tera_byte_0": null,
|
||||
"tera_byte_1": null,
|
||||
"tera_byte_2": null
|
||||
"byte": "",
|
||||
"giga_byte": "",
|
||||
"kilo_byte": "",
|
||||
"mega_byte": "",
|
||||
"tera_byte": ""
|
||||
},
|
||||
"table_columns": {
|
||||
"dimensions": "尺寸",
|
||||
|
@ -248,7 +254,11 @@
|
|||
},
|
||||
"title": "下载"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "动态模式",
|
||||
|
@ -264,19 +274,20 @@
|
|||
},
|
||||
"flash": {
|
||||
"auto": "自动",
|
||||
"did_not_fire": null,
|
||||
"fired": null,
|
||||
"no_flash": null,
|
||||
"no_flash_function": null,
|
||||
"did_not_fire": "",
|
||||
"fired": "",
|
||||
"no_flash": "",
|
||||
"no_flash_function": "",
|
||||
"off": "关闭",
|
||||
"on": "使用",
|
||||
"red_eye_reduction": "减轻红眼",
|
||||
"return_detected": null,
|
||||
"return_not_detected": null
|
||||
"return_detected": "",
|
||||
"return_not_detected": ""
|
||||
},
|
||||
"name": {
|
||||
"aperture": "光圈",
|
||||
"camera": "相机",
|
||||
"coordinates": "",
|
||||
"date_shot": "拍摄日期",
|
||||
"exposure": "曝光",
|
||||
"exposure_program": "模式",
|
||||
|
@ -288,6 +299,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "新增分享",
|
||||
"copy_link": "复制链接",
|
||||
|
@ -305,6 +325,13 @@
|
|||
"places": "地点",
|
||||
"settings": "设置"
|
||||
},
|
||||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"loading_album": "载入相册",
|
||||
"login": "登入",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -61,17 +61,17 @@
|
|||
"description": "簡單及易用的照片庫給個人伺服器"
|
||||
},
|
||||
"people_page": {
|
||||
"action_label": {
|
||||
"change_label": "",
|
||||
"detach_images": "",
|
||||
"merge_people": "",
|
||||
"move_faces": ""
|
||||
},
|
||||
"face_group": {
|
||||
"label_placeholder": "名稱",
|
||||
"unlabeled": "未命名",
|
||||
"unlabeled_person": "未命名人物"
|
||||
},
|
||||
"action_label": {
|
||||
"change_label": null,
|
||||
"merge_face": null,
|
||||
"detach_face": null,
|
||||
"move_faces": null
|
||||
},
|
||||
"modal": {
|
||||
"action": {
|
||||
"merge": "合併"
|
||||
|
@ -215,30 +215,36 @@
|
|||
},
|
||||
"sidebar": {
|
||||
"album": {
|
||||
"album_cover": "",
|
||||
"download": {
|
||||
"high-resolutions": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"originals": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"thumbnails": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
},
|
||||
"web-videos": {
|
||||
"description": "",
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"reset_cover": "",
|
||||
"set_cover": "",
|
||||
"title_placeholder": "相簿名稱"
|
||||
},
|
||||
"download": {
|
||||
"filesize": {
|
||||
"byte": null,
|
||||
"giga_byte": null,
|
||||
"kilo_byte": null,
|
||||
"mega_byte": null,
|
||||
"tera_byte": null,
|
||||
"byte_0": null,
|
||||
"byte_1": null,
|
||||
"byte_2": null,
|
||||
"giga_byte_0": null,
|
||||
"giga_byte_1": null,
|
||||
"giga_byte_2": null,
|
||||
"kilo_byte_0": null,
|
||||
"kilo_byte_1": null,
|
||||
"kilo_byte_2": null,
|
||||
"mega_byte_0": null,
|
||||
"mega_byte_1": null,
|
||||
"mega_byte_2": null,
|
||||
"tera_byte_0": null,
|
||||
"tera_byte_1": null,
|
||||
"tera_byte_2": null
|
||||
"byte": "",
|
||||
"giga_byte": "",
|
||||
"kilo_byte": "",
|
||||
"mega_byte": "",
|
||||
"tera_byte": ""
|
||||
},
|
||||
"table_columns": {
|
||||
"dimensions": "尺寸",
|
||||
|
@ -248,7 +254,11 @@
|
|||
},
|
||||
"title": "下載"
|
||||
},
|
||||
"location": {
|
||||
"title": ""
|
||||
},
|
||||
"media": {
|
||||
"album_path": "",
|
||||
"exif": {
|
||||
"exposure_program": {
|
||||
"action_program": "動態模式",
|
||||
|
@ -264,19 +274,20 @@
|
|||
},
|
||||
"flash": {
|
||||
"auto": "自動",
|
||||
"did_not_fire": null,
|
||||
"fired": null,
|
||||
"no_flash": null,
|
||||
"no_flash_function": null,
|
||||
"did_not_fire": "",
|
||||
"fired": "",
|
||||
"no_flash": "",
|
||||
"no_flash_function": "",
|
||||
"off": "關閉",
|
||||
"on": "使用",
|
||||
"red_eye_reduction": "減輕紅眼",
|
||||
"return_detected": null,
|
||||
"return_not_detected": null
|
||||
"return_detected": "",
|
||||
"return_not_detected": ""
|
||||
},
|
||||
"name": {
|
||||
"aperture": "光圈",
|
||||
"camera": "相機",
|
||||
"coordinates": "",
|
||||
"date_shot": "拍攝日期",
|
||||
"exposure": "曝光",
|
||||
"exposure_program": "模式",
|
||||
|
@ -288,6 +299,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"action_label": {
|
||||
"detach_image": "",
|
||||
"merge_face": "",
|
||||
"move_face": ""
|
||||
},
|
||||
"confirm_image_detach": "",
|
||||
"title": ""
|
||||
},
|
||||
"sharing": {
|
||||
"add_share": "新增分享",
|
||||
"copy_link": "複製連結",
|
||||
|
@ -305,6 +325,13 @@
|
|||
"places": "地點",
|
||||
"settings": "設定"
|
||||
},
|
||||
"timeline_filter": {
|
||||
"date": {
|
||||
"dropdown_all": "",
|
||||
"dropdown_year": "",
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"loading_album": "載入相簿",
|
||||
"login": "登入",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import classNames, { Argument as ClassNamesArg } from 'classnames'
|
||||
// import { overrideTailwindClasses } from 'tailwind-override'
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface DebouncedFn<F extends (...args: any[]) => any> {
|
||||
|
@ -41,3 +43,8 @@ export function isNil(value: any): value is undefined | null {
|
|||
export function exhaustiveCheck(value: never) {
|
||||
throw new Error(`Exhaustive check failed with value: ${value}`)
|
||||
}
|
||||
|
||||
export function tailwindClassNames(...args: ClassNamesArg[]) {
|
||||
// return overrideTailwindClasses(classNames(args))
|
||||
return classNames(args)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import classNames, { Argument as ClassNamesArg } from 'classnames'
|
|||
import { ReactComponent as ActionArrowIcon } from './icons/textboxActionArrow.svg'
|
||||
import { ReactComponent as LoadingSpinnerIcon } from './icons/textboxLoadingSpinner.svg'
|
||||
import styled from 'styled-components'
|
||||
import { tailwindClassNames } from '../../helpers/utils'
|
||||
|
||||
type TextFieldProps = {
|
||||
label?: string
|
||||
|
@ -164,7 +165,10 @@ export const Submit = ({
|
|||
...props
|
||||
}: SubmitProps & React.ButtonHTMLAttributes<HTMLInputElement>) => (
|
||||
<input
|
||||
className={classNames(buttonStyles({ variant, background }), className)}
|
||||
className={tailwindClassNames(
|
||||
buttonStyles({ variant, background }),
|
||||
className
|
||||
)}
|
||||
type="submit"
|
||||
value={children}
|
||||
{...props}
|
||||
|
@ -179,7 +183,10 @@ export const Button = ({
|
|||
...props
|
||||
}: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button
|
||||
className={classNames(buttonStyles({ variant, background }), className)}
|
||||
className={tailwindClassNames(
|
||||
buttonStyles({ variant, background }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
Loading…
Reference in New Issue