1
Fork 0

Merge pull request #554 from photoview/viktorstrate/issue319

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

View File

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

View File

@ -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.marshalNMedia2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
}
func (ec *executionContext) _ImageFace_rectangle(ctx context.Context, field graphql.CollectedField, obj *models.ImageFace) (ret graphql.Marshaler) {
@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

25
ui/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { gql, useMutation } from '@apollo/client'
import { BaseMutationOptions, gql, useMutation } from '@apollo/client'
import React, { useEffect, useState } from 'react'
import { 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),
},
{

View File

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

View File

@ -1,4 +1,4 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import { gql, PureQueryOptions, useMutation, useQuery } from '@apollo/client'
import React, { useState } from 'react'
import { 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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: mediaGeoJson
// ====================================================
export interface mediaGeoJson {
/**
* Get media owned by the logged in user, returned in GeoJson format
*/
myMediaGeoJson: any
}

View File

@ -1,80 +0,0 @@
import type mapboxgl from 'mapbox-gl'
import type geojson from 'geojson'
import React from 'react'
import ReactDOM from 'react-dom'
import MapClusterMarker from './MapClusterMarker'
import { MediaMarker } from './MapPresentMarker'
import { PlacesAction } from './placesReducer'
const markers: { [key: string]: mapboxgl.Marker } = {}
let markersOnScreen: typeof markers = {}
type makeUpdateMarkersArgs = {
map: mapboxgl.Map
mapboxLibrary: typeof mapboxgl
dispatchMarkerMedia: React.Dispatch<PlacesAction>
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
}
export const makeUpdateMarkers = ({
map,
mapboxLibrary,
dispatchMarkerMedia,
}: makeUpdateMarkersArgs) => () => {
const newMarkers: typeof markers = {}
const features = map.querySourceFeatures('media')
// for every media on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (const feature of features) {
const point = feature.geometry as geojson.Point
const coords = point.coordinates as [number, number]
const props = feature.properties as MediaMarker
if (props == null) {
console.warn('WARN: geojson feature had no properties', feature)
continue
}
const id = props.cluster
? `cluster_${props.cluster_id}`
: `media_${props.media_id}`
let marker = markers[id]
if (!marker) {
const el = createClusterPopupElement(props, {
dispatchMarkerMedia,
})
marker = markers[id] = new mapboxLibrary.Marker({
element: el,
}).setLngLat(coords)
}
newMarkers[id] = marker
if (!markersOnScreen[id]) marker.addTo(map)
}
// for every marker we've added previously, remove those that are no longer visible
for (const id in markersOnScreen) {
if (!newMarkers[id]) markersOnScreen[id].remove()
}
markersOnScreen = newMarkers
}
function createClusterPopupElement(
geojsonProps: MediaMarker,
{
dispatchMarkerMedia,
}: {
dispatchMarkerMedia: React.Dispatch<PlacesAction>
}
) {
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
const el = document.createElement('div')
ReactDOM.render(
<MapClusterMarker
marker={geojsonProps}
dispatchMarkerMedia={dispatchMarkerMedia}
/>,
el
)
return el
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

@ -0,0 +1,80 @@
import React, { useState, useRef, useEffect } from 'react'
import { gql, useQuery } from '@apollo/client'
import type mapboxgl from 'mapbox-gl'
import styled from 'styled-components'
import 'mapbox-gl/dist/mapbox-gl.css'
import { mapboxToken } from './__generated__/mapboxToken'
const MAPBOX_TOKEN_QUERY = gql`
query mapboxToken {
mapboxToken
myMediaGeoJson
}
`
const MapContainer = styled.div`
width: 100%;
height: 100%;
`
type MapboxMapProps = {
configureMapbox(map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl): void
mapboxOptions?: Partial<mapboxgl.MapboxOptions>
}
const useMapboxMap = ({
configureMapbox,
mapboxOptions = undefined,
}: MapboxMapProps) => {
const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl>()
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<mapboxgl.Map | null>(null)
const { data: mapboxData } = useQuery<mapboxToken>(MAPBOX_TOKEN_QUERY, {
fetchPolicy: 'cache-first',
})
useEffect(() => {
async function loadMapboxLibrary() {
const mapbox = (await import('mapbox-gl')).default
setMapboxLibrary(mapbox)
}
loadMapboxLibrary()
}, [])
useEffect(() => {
if (
mapboxLibrary == null ||
mapContainer.current == null ||
mapboxData == null ||
map.current != null
) {
return
}
if (mapboxData.mapboxToken)
mapboxLibrary.accessToken = mapboxData.mapboxToken
map.current = new mapboxLibrary.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v11',
...mapboxOptions,
})
configureMapbox(map.current, mapboxLibrary)
map.current?.resize()
}, [mapContainer, mapboxLibrary, mapboxData])
map.current?.resize()
return {
mapContainer: <MapContainer ref={mapContainer}></MapContainer>,
mapboxMap: map.current,
mapboxLibrary,
mapboxToken: mapboxData?.mapboxToken || null,
}
}
export default useMapboxMap

View File

@ -0,0 +1,19 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: mapboxToken
// ====================================================
export interface mapboxToken {
/**
* Get the mapbox api token, returns null if mapbox is not enabled
*/
mapboxToken: string | null
/**
* Get media owned by the logged in user, returned in GeoJson format
*/
myMediaGeoJson: any
}

View File

@ -0,0 +1,93 @@
import type mapboxgl from 'mapbox-gl'
import type geojson from 'geojson'
import React from 'react'
import ReactDOM from 'react-dom'
import MapClusterMarker from '../../Pages/PlacesPage/MapClusterMarker'
import { MediaMarker } from '../../Pages/PlacesPage/MapPresentMarker'
import { PlacesAction } from '../../Pages/PlacesPage/placesReducer'
const markers: { [key: string]: mapboxgl.Marker } = {}
let markersOnScreen: typeof markers = {}
type registerMediaMarkersArgs = {
map: mapboxgl.Map
mapboxLibrary: typeof mapboxgl
dispatchMarkerMedia: React.Dispatch<PlacesAction>
}
/**
* Add appropriate event handlers to the map, to render and update media markers
* Expects the provided mapbox map to contain geojson source of media
*/
export const registerMediaMarkers = (args: registerMediaMarkersArgs) => {
const updateMarkers = makeUpdateMarkers(args)
args.map.on('move', updateMarkers)
args.map.on('moveend', updateMarkers)
args.map.on('sourcedata', updateMarkers)
updateMarkers()
}
/**
* Make a function that can be passed to Mapbox to tell it how to render and update the image markers
*/
const makeUpdateMarkers =
({ map, mapboxLibrary, dispatchMarkerMedia }: registerMediaMarkersArgs) =>
() => {
const newMarkers: typeof markers = {}
const features = map.querySourceFeatures('media')
// for every media on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (const feature of features) {
const point = feature.geometry as geojson.Point
const coords = point.coordinates as [number, number]
const props = feature.properties as MediaMarker
if (props == null) {
console.warn('WARN: geojson feature had no properties', feature)
continue
}
const id = props.cluster
? `cluster_${props.cluster_id}`
: `media_${props.media_id}`
let marker = markers[id]
if (!marker) {
const el = createClusterPopupElement(props, {
dispatchMarkerMedia,
})
marker = markers[id] = new mapboxLibrary.Marker({
element: el,
}).setLngLat(coords)
}
newMarkers[id] = marker
if (!markersOnScreen[id]) marker.addTo(map)
}
// for every marker we've added previously, remove those that are no longer visible
for (const id in markersOnScreen) {
if (!newMarkers[id]) markersOnScreen[id].remove()
}
markersOnScreen = newMarkers
}
function createClusterPopupElement(
geojsonProps: MediaMarker,
{
dispatchMarkerMedia,
}: {
dispatchMarkerMedia: React.Dispatch<PlacesAction>
}
) {
// setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
const el = document.createElement('div')
ReactDOM.render(
<MapClusterMarker
marker={geojsonProps}
dispatchMarkerMedia={dispatchMarkerMedia}
/>,
el
)
return el
}

View File

@ -3,7 +3,6 @@ import styled from 'styled-components'
import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail'
import 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
}

View File

@ -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={['/']}>

View File

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

View File

@ -0,0 +1,87 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { MockedProvider } from '@apollo/client/testing'
import MediaSidebar, { MediaSidebarMedia } from './MediaSidebar'
import { MediaType } from '../../../__generated__/globalTypes'
import { MemoryRouter } from 'react-router'
import * as authentication from '../../../helpers/authentication'
jest.mock('../../../helpers/authentication.ts')
const authToken = authentication.authToken as jest.Mock<
ReturnType<typeof authentication.authToken>
>
describe('MediaSidebar', () => {
const media: MediaSidebarMedia = {
__typename: 'Media',
id: '6867',
title: '122A6069.jpg',
type: MediaType.Photo,
thumbnail: {
__typename: 'MediaURL',
url: 'http://localhost:4001/photo/thumbnail.jpg',
width: 1024,
height: 839,
},
highRes: {
__typename: 'MediaURL',
url: 'http://localhost:4001/photo/highres.jpg',
width: 5322,
height: 4362,
},
videoWeb: null,
album: {
__typename: 'Album',
id: '2294',
title: 'album_name',
},
}
test('render sample image, unauthorized', () => {
authToken.mockImplementation(() => null)
render(
<MockedProvider mocks={[]} addTypename={false}>
<MemoryRouter>
<MediaSidebar media={media} />
</MemoryRouter>
</MockedProvider>
)
expect(screen.getByText('122A6069.jpg')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'http://localhost:4001/photo/highres.jpg'
)
expect(
screen.queryByText('Set as album cover photo')
).not.toBeInTheDocument()
expect(screen.queryByText('Sharing options')).not.toBeInTheDocument()
})
test('render sample image, authorized', () => {
authToken.mockImplementation(() => 'token-here')
render(
<MockedProvider mocks={[]} addTypename={false}>
<MemoryRouter>
<MediaSidebar media={media} />
</MemoryRouter>
</MockedProvider>
)
expect(screen.getByText('122A6069.jpg')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'http://localhost:4001/photo/highres.jpg'
)
expect(screen.getByText('Set as album cover photo')).toBeInTheDocument()
expect(screen.getByText('Album path')).toBeInTheDocument()
screen.debug()
})
})

View File

@ -0,0 +1,300 @@
import { gql, useLazyQuery } from '@apollo/client'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { authToken } from '../../../helpers/authentication'
import { isNil } from '../../../helpers/utils'
import { MediaType } from '../../../__generated__/globalTypes'
import { SidebarFacesOverlay } from '../../facesOverlay/FacesOverlay'
import {
ProtectedImage,
ProtectedVideo,
ProtectedVideoProps_Media,
} from '../../photoGallery/ProtectedMedia'
import { SidebarPhotoCover } from '../AlbumCovers'
import { SidebarPhotoShare } from '../Sharing'
import SidebarMediaDownload from '../SidebarDownloadMedia'
import SidebarHeader from '../SidebarHeader'
import { sidebarDownloadQuery_media_downloads } from '../__generated__/sidebarDownloadQuery'
import ExifDetails from './MediaSidebarExif'
import MediaSidebarPeople from './MediaSidebarPeople'
import MediaSidebarMap from './MediaSidebarMap'
import {
sidebarMediaQuery,
sidebarMediaQueryVariables,
sidebarMediaQuery_media_album_path,
sidebarMediaQuery_media_exif,
sidebarMediaQuery_media_faces,
sidebarMediaQuery_media_thumbnail,
sidebarMediaQuery_media_videoMetadata,
} from './__generated__/sidebarMediaQuery'
import { BreadcrumbList } from '../../album/AlbumTitle'
export const SIDEBAR_MEDIA_QUERY = gql`
query sidebarMediaQuery($id: ID!) {
media(id: $id) {
id
title
type
highRes {
url
width
height
}
thumbnail {
url
width
height
}
videoWeb {
url
width
height
}
videoMetadata {
id
width
height
duration
codec
framerate
bitrate
colorProfile
audio
}
exif {
id
camera
maker
lens
dateShot
exposure
aperture
iso
focalLength
flash
exposureProgram
coordinates {
latitude
longitude
}
}
album {
id
title
path {
id
title
}
}
faces {
id
rectangle {
minX
maxX
minY
maxY
}
faceGroup {
id
label
imageFaceCount
}
media {
id
title
thumbnail {
url
width
height
}
}
}
}
}
`
const PreviewImage = styled(ProtectedImage)`
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
object-fit: contain;
`
const PreviewVideo = styled(ProtectedVideo)`
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
`
interface PreviewMediaPropsMedia extends ProtectedVideoProps_Media {
type: MediaType
}
type PreviewMediaProps = {
media: PreviewMediaPropsMedia
previewImage?: {
url: string
}
}
const PreviewMedia = ({ media, previewImage }: PreviewMediaProps) => {
if (media.type === MediaType.Photo) {
return <PreviewImage src={previewImage?.url} />
}
if (media.type === MediaType.Video) {
return <PreviewVideo media={media} />
}
return <div>ERROR: Unknown media type: {media.type}</div>
}
type SidebarContentProps = {
media: MediaSidebarMedia
hidePreview?: boolean
}
const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => {
const { t } = useTranslation()
let previewImage = null
if (media.highRes) previewImage = media.highRes
else if (media.thumbnail) previewImage = media.thumbnail
const imageAspect =
previewImage?.width && previewImage?.height
? previewImage.height / previewImage.width
: 3 / 2
let sidebarMap = null
const mediaCoordinates = media.exif?.coordinates
if (mediaCoordinates) {
sidebarMap = <MediaSidebarMap coordinates={mediaCoordinates} />
}
let albumPath = null
const mediaAlbum = media.album
if (!isNil(mediaAlbum)) {
const pathElms = [...(mediaAlbum.path ?? []), mediaAlbum].map(album => (
<li key={album.id} className="inline-block hover:underline">
<Link
className="text-blue-900 hover:underline"
to={`/album/${album.id}`}
>
{album.title}
</Link>
</li>
))
albumPath = (
<div className="mx-4 my-4">
<h2 className="uppercase text-xs text-gray-900 font-semibold">
{t('sidebar.media.album_path', 'Album path')}
</h2>
<BreadcrumbList hideLastArrow={true}>{pathElms}</BreadcrumbList>
</div>
)
}
return (
<div>
<SidebarHeader title={media.title ?? 'Loading...'} />
<div className="lg:mx-4">
{!hidePreview && (
<div
className="w-full h-0 relative"
style={{ paddingTop: `${Math.min(imageAspect, 0.75) * 100}%` }}
>
<PreviewMedia
previewImage={previewImage || undefined}
media={media}
/>
<SidebarFacesOverlay media={media} />
</div>
)}
</div>
<ExifDetails media={media} />
{albumPath}
<MediaSidebarPeople media={media} />
{sidebarMap}
<SidebarMediaDownload media={media} />
<SidebarPhotoShare id={media.id} />
<div className="mt-8">
<SidebarPhotoCover cover_id={media.id} />
</div>
</div>
)
}
export interface MediaSidebarMedia {
__typename: 'Media'
id: string
title?: string
type: MediaType
highRes?: null | {
__typename: 'MediaURL'
url: string
width?: number
height?: number
}
thumbnail?: sidebarMediaQuery_media_thumbnail | null
videoWeb?: null | {
__typename: 'MediaURL'
url: string
width?: number
height?: number
}
videoMetadata?: sidebarMediaQuery_media_videoMetadata | null
exif?: sidebarMediaQuery_media_exif | null
faces?: sidebarMediaQuery_media_faces[]
downloads?: sidebarDownloadQuery_media_downloads[]
album?: {
__typename: 'Album'
id: string
title: string
path?: sidebarMediaQuery_media_album_path[]
}
}
type MediaSidebarType = {
media: MediaSidebarMedia
hidePreview?: boolean
}
const MediaSidebar = ({ media, hidePreview }: MediaSidebarType) => {
const [loadMedia, { loading, error, data }] = useLazyQuery<
sidebarMediaQuery,
sidebarMediaQueryVariables
>(SIDEBAR_MEDIA_QUERY)
useEffect(() => {
if (media != null && authToken()) {
loadMedia({
variables: {
id: media.id,
},
})
}
}, [media])
if (!media) return null
if (!authToken()) {
return <SidebarContent media={media} hidePreview={hidePreview} />
}
if (error) return <div>{error.message}</div>
if (loading || data == null) {
return <SidebarContent media={media} hidePreview={hidePreview} />
}
return <SidebarContent media={data.media} hidePreview={hidePreview} />
}
export default MediaSidebar

View File

@ -1,13 +1,15 @@
import React from 'react'
import { 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()
})
})

View File

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

View File

@ -0,0 +1,56 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { isNil } from '../../../helpers/utils'
import useMapboxMap from '../../mapbox/MapboxMap'
import { SidebarSection, SidebarSectionTitle } from '../SidebarComponents'
import { sidebarMediaQuery_media_exif_coordinates } from './__generated__/sidebarMediaQuery'
type MediaSidebarMapProps = {
coordinates: sidebarMediaQuery_media_exif_coordinates
}
const MediaSidebarMap = ({ coordinates }: MediaSidebarMapProps) => {
const { t } = useTranslation()
const { mapContainer, mapboxToken } = useMapboxMap({
mapboxOptions: {
interactive: false,
zoom: 12,
center: {
lat: coordinates.latitude,
lng: coordinates.longitude,
},
},
configureMapbox: (map, mapboxLibrary) => {
// todo
map.addControl(
new mapboxLibrary.NavigationControl({ showCompass: false })
)
const centerMarker = new mapboxLibrary.Marker({
color: 'red',
scale: 0.8,
})
centerMarker.setLngLat({
lat: coordinates.latitude,
lng: coordinates.longitude,
})
centerMarker.addTo(map)
},
})
if (isNil(mapboxToken)) {
return null
}
return (
<SidebarSection>
<SidebarSectionTitle>
{t('sidebar.location.title', 'Location')}
</SidebarSectionTitle>
<div className="w-full h-64">{mapContainer}</div>
</SidebarSection>
)
}
export default MediaSidebarMap

View File

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

View File

@ -0,0 +1,229 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from './../../../../__generated__/globalTypes'
// ====================================================
// GraphQL query operation: sidebarMediaQuery
// ====================================================
export interface sidebarMediaQuery_media_highRes {
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string
/**
* Width of the image in pixels
*/
width: number
/**
* Height of the image in pixels
*/
height: number
}
export interface sidebarMediaQuery_media_thumbnail {
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string
/**
* Width of the image in pixels
*/
width: number
/**
* Height of the image in pixels
*/
height: number
}
export interface sidebarMediaQuery_media_videoWeb {
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string
/**
* Width of the image in pixels
*/
width: number
/**
* Height of the image in pixels
*/
height: number
}
export interface sidebarMediaQuery_media_videoMetadata {
__typename: 'VideoMetadata'
id: string
width: number
height: number
duration: number
codec: string | null
framerate: number | null
bitrate: string | null
colorProfile: string | null
audio: string | null
}
export interface sidebarMediaQuery_media_exif_coordinates {
__typename: 'Coordinates'
/**
* GPS latitude in degrees
*/
latitude: number
/**
* GPS longitude in degrees
*/
longitude: number
}
export interface sidebarMediaQuery_media_exif {
__typename: 'MediaEXIF'
id: string
/**
* The model name of the camera
*/
camera: string | null
/**
* The maker of the camera
*/
maker: string | null
/**
* The name of the lens
*/
lens: string | null
dateShot: any | null
/**
* The exposure time of the image
*/
exposure: number | null
/**
* The aperature stops of the image
*/
aperture: number | null
/**
* The ISO setting of the image
*/
iso: number | null
/**
* The focal length of the lens, when the image was taken
*/
focalLength: number | null
/**
* A formatted description of the flash settings, when the image was taken
*/
flash: number | null
/**
* An index describing the mode for adjusting the exposure of the image
*/
exposureProgram: number | null
/**
* GPS coordinates of where the image was taken
*/
coordinates: sidebarMediaQuery_media_exif_coordinates | null
}
export interface sidebarMediaQuery_media_album_path {
__typename: 'Album'
id: string
title: string
}
export interface sidebarMediaQuery_media_album {
__typename: 'Album'
id: string
title: string
path: sidebarMediaQuery_media_album_path[]
}
export interface sidebarMediaQuery_media_faces_rectangle {
__typename: 'FaceRectangle'
minX: number
maxX: number
minY: number
maxY: number
}
export interface sidebarMediaQuery_media_faces_faceGroup {
__typename: 'FaceGroup'
id: string
label: string | null
imageFaceCount: number
}
export interface sidebarMediaQuery_media_faces_media_thumbnail {
__typename: 'MediaURL'
/**
* URL for previewing the image
*/
url: string
/**
* Width of the image in pixels
*/
width: number
/**
* Height of the image in pixels
*/
height: number
}
export interface sidebarMediaQuery_media_faces_media {
__typename: 'Media'
id: string
title: string
/**
* URL to display the media in a smaller resolution
*/
thumbnail: sidebarMediaQuery_media_faces_media_thumbnail | null
}
export interface sidebarMediaQuery_media_faces {
__typename: 'ImageFace'
id: string
rectangle: sidebarMediaQuery_media_faces_rectangle
faceGroup: sidebarMediaQuery_media_faces_faceGroup
media: sidebarMediaQuery_media_faces_media
}
export interface sidebarMediaQuery_media {
__typename: 'Media'
id: string
title: string
type: MediaType
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: sidebarMediaQuery_media_highRes | null
/**
* URL to display the media in a smaller resolution
*/
thumbnail: sidebarMediaQuery_media_thumbnail | null
/**
* URL to get the video in a web format that can be played in the browser, will be null for photos
*/
videoWeb: sidebarMediaQuery_media_videoWeb | null
videoMetadata: sidebarMediaQuery_media_videoMetadata | null
exif: sidebarMediaQuery_media_exif | null
/**
* The album that holds the media
*/
album: sidebarMediaQuery_media_album
faces: sidebarMediaQuery_media_faces[]
}
export interface sidebarMediaQuery {
/**
* Get media by id, user must own the media or be admin.
* If valid tokenCredentials are provided, the media may be retrived without further authentication
*/
media: sidebarMediaQuery_media
}
export interface sidebarMediaQueryVariables {
id: string
}

View File

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

After

Width:  |  Height:  |  Size: 583 B

View File

@ -13,7 +13,6 @@ import {
sidebareDeleteShare,
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
}

View File

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

View File

@ -1,26 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: setAlbumCoverID
// ====================================================
export interface setAlbumCoverID_setAlbumCoverID {
__typename: 'Album'
id: string
coverID: string
}
export interface setAlbumCoverID {
/**
* Assign a cover image to an album, set coverID to -1 to remove the current one
*/
setAlbumCoverID: setAlbumCoverID_setAlbumCoverID
}
export interface setAlbumCoverIDVariables {
albumID: string
coverID: string
}

View File

@ -1,36 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: sidbarGetAlbumShares
// ====================================================
export interface sidbarGetAlbumShares_album_shares {
__typename: "ShareToken";
id: string;
token: string;
/**
* Whether or not a password is needed to access the share
*/
hasPassword: boolean;
}
export interface sidbarGetAlbumShares_album {
__typename: "Album";
id: string;
shares: (sidbarGetAlbumShares_album_shares | null)[] | null;
}
export interface sidbarGetAlbumShares {
/**
* Get album by id, user must own the album or be admin
* If valid tokenCredentials are provided, the album may be retrived without further authentication
*/
album: sidbarGetAlbumShares_album;
}
export interface sidbarGetAlbumSharesVariables {
id: string;
}

View File

@ -1,36 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: sidbarGetPhotoShares
// ====================================================
export interface sidbarGetPhotoShares_media_shares {
__typename: "ShareToken";
id: string;
token: string;
/**
* Whether or not a password is needed to access the share
*/
hasPassword: boolean;
}
export interface sidbarGetPhotoShares_media {
__typename: "Media";
id: string;
shares: sidbarGetPhotoShares_media_shares[];
}
export interface sidbarGetPhotoShares {
/**
* Get media by id, user must own the media or be admin.
* If valid tokenCredentials are provided, the media may be retrived without further authentication
*/
media: sidbarGetPhotoShares_media;
}
export interface sidbarGetPhotoSharesVariables {
id: string;
}

View File

@ -1,167 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from "./../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: sidebarPhoto
// ====================================================
export interface sidebarPhoto_media_highRes {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
/**
* Width of the image in pixels
*/
width: number;
/**
* Height of the image in pixels
*/
height: number;
}
export interface sidebarPhoto_media_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
/**
* Width of the image in pixels
*/
width: number;
/**
* Height of the image in pixels
*/
height: number;
}
export interface sidebarPhoto_media_videoWeb {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
/**
* Width of the image in pixels
*/
width: number;
/**
* Height of the image in pixels
*/
height: number;
}
export interface sidebarPhoto_media_videoMetadata {
__typename: "VideoMetadata";
id: string;
width: number;
height: number;
duration: number;
codec: string | null;
framerate: number | null;
bitrate: string | null;
colorProfile: string | null;
audio: string | null;
}
export interface sidebarPhoto_media_exif {
__typename: "MediaEXIF";
id: string;
/**
* The model name of the camera
*/
camera: string | null;
/**
* The maker of the camera
*/
maker: string | null;
/**
* The name of the lens
*/
lens: string | null;
dateShot: any | null;
/**
* The exposure time of the image
*/
exposure: number | null;
/**
* The aperature stops of the image
*/
aperture: number | null;
/**
* The ISO setting of the image
*/
iso: number | null;
/**
* The focal length of the lens, when the image was taken
*/
focalLength: number | null;
/**
* A formatted description of the flash settings, when the image was taken
*/
flash: number | null;
/**
* An index describing the mode for adjusting the exposure of the image
*/
exposureProgram: number | null;
}
export interface sidebarPhoto_media_faces_rectangle {
__typename: "FaceRectangle";
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface sidebarPhoto_media_faces_faceGroup {
__typename: "FaceGroup";
id: string;
}
export interface sidebarPhoto_media_faces {
__typename: "ImageFace";
id: string;
rectangle: sidebarPhoto_media_faces_rectangle;
faceGroup: sidebarPhoto_media_faces_faceGroup;
}
export interface sidebarPhoto_media {
__typename: "Media";
id: string;
title: string;
type: MediaType;
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: sidebarPhoto_media_highRes | null;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: sidebarPhoto_media_thumbnail | null;
/**
* URL to get the video in a web format that can be played in the browser, will be null for photos
*/
videoWeb: sidebarPhoto_media_videoWeb | null;
videoMetadata: sidebarPhoto_media_videoMetadata | null;
exif: sidebarPhoto_media_exif | null;
faces: sidebarPhoto_media_faces[];
}
export interface sidebarPhoto {
/**
* Get media by id, user must own the media or be admin.
* If valid tokenCredentials are provided, the media may be retrived without further authentication
*/
media: sidebarPhoto_media;
}
export interface sidebarPhotoVariables {
id: string;
}

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

@ -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": {

View File

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

View File

@ -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": {

View File

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

View File

@ -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": {

View File

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

View File

@ -0,0 +1,19 @@
{
"people_page": {
"action_label": {
"change_label": null,
"merge_people": null,
"detach_images": null,
"move_faces": null
},
"modal": {
"merge_people_groups": {
"description": "Todas as imagens neste grupo de caras serão juntas no grupo selecionado.",
"destination_table": {
"title": "Selecione a cara de destino"
},
"title": "Juntar grupos de cara"
}
}
}
}

View File

@ -63,8 +63,8 @@
"people_page": {
"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": ""
}
},

View File

@ -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": {

View File

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

View File

@ -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": {

View File

@ -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": "登入",

View File

@ -0,0 +1,42 @@
{
"people_page": {
"action_label": {
"change_label": null,
"merge_people": null,
"detach_images": null,
"move_faces": null
},
"modal": {
"merge_people_groups": {
"description": "所有在此的脸孔群组将会合并到已选择的脸孔群组",
"destination_table": {
"title": "选择目标脸孔群组"
},
"title": "合并脸孔群组"
}
}
},
"sidebar": {
"download": {
"filesize": {
"byte": null,
"giga_byte": null,
"kilo_byte": null,
"mega_byte": null,
"tera_byte": null
}
},
"media": {
"exif": {
"flash": {
"did_not_fire": null,
"fired": null,
"no_flash": null,
"no_flash_function": null,
"return_detected": null,
"return_not_detected": null
}
}
}
}
}

View File

@ -61,17 +61,17 @@
"description": "簡單及易用的照片庫給個人伺服器"
},
"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": "登入",

View File

@ -0,0 +1,42 @@
{
"people_page": {
"action_label": {
"change_label": null,
"merge_people": null,
"detach_images": null,
"move_faces": null
},
"modal": {
"merge_people_groups": {
"description": "所有在此的臉孔群組將會合併到已選擇的臉孔群組",
"destination_table": {
"title": "選擇目標臉孔群組"
},
"title": "合併臉孔群組"
}
}
},
"sidebar": {
"download": {
"filesize": {
"byte": null,
"giga_byte": null,
"kilo_byte": null,
"mega_byte": null,
"tera_byte": null
}
},
"media": {
"exif": {
"flash": {
"did_not_fire": null,
"fired": null,
"no_flash": null,
"no_flash_function": null,
"return_detected": null,
"return_not_detected": null
}
}
}
}
}

View File

@ -1,3 +1,5 @@
import classNames, { Argument as ClassNamesArg } from 'classnames'
// import { overrideTailwindClasses } from 'tailwind-override'
/* eslint-disable @typescript-eslint/no-explicit-any */
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)
}

View File

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